Files
ResolveSpec/pkg/resolvemcp/resolvemcp.go
T
Hein ad2f54693f feat(resolvemcp): require authentication on MCP endpoints and enforce model rules on writes
Guard() rejects unauthenticated callers (no guest/optional mode); Setup*/New* helpers take a
SecurityList and have explicit *Unauthenticated variants. Model rules now reach the security
hooks, create checks CanCreate (security.CheckModelCreateAllowed), create/update validate keys
against the model's writable columns, update sets only given keys (NULL allowed), update and
delete go through row security via a new BeforeScan hook, and the annotation tool is opt-in
(Config.EnableAnnotations) and runs BeforeHandle.
2026-10-01 13:31:13 +02:00

197 lines
8.5 KiB
Go

// Package resolvemcp exposes registered database models as Model Context Protocol (MCP) tools
// and resources over HTTP/SSE transport.
//
// It mirrors the resolvespec package patterns:
// - Same model registration API
// - Same filter, sort, cursor pagination, preload options
// - Same lifecycle hook system
//
// Usage:
//
// handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{BaseURL: "http://localhost:8080"})
// handler.RegisterModel("public", "users", &User{})
//
// r := mux.NewRouter()
// resolvemcp.SetupMuxRoutes(r, handler, securityList) // requires an authenticated caller
package resolvemcp
import (
"net/http"
"runtime/debug"
"github.com/gorilla/mux"
"github.com/uptrace/bun"
bunrouter "github.com/uptrace/bunrouter"
"gorm.io/gorm"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
// Config holds configuration for the resolvemcp handler.
type Config struct {
// BaseURL is the public-facing base URL of the server (e.g. "http://localhost:8080").
// It is sent to MCP clients during the SSE handshake so they know where to POST messages.
BaseURL string
// BasePath is the URL path prefix where the MCP endpoints are mounted (e.g. "/mcp").
// If empty, the path is detected from each incoming request automatically.
BasePath string
// EnableAnnotations registers the resolvespec_annotate tool. Off by default: annotations
// are free text that agents read back, so enabling the tool opens a write channel into
// agent-visible text. When on, every call runs the BeforeHandle hooks (operation
// "annotate_set" / "annotate_get") and the writes run in a transaction with OnTxBegin.
EnableAnnotations bool
}
// NewHandlerWithGORM creates a Handler backed by a GORM database connection.
func NewHandlerWithGORM(db *gorm.DB, cfg Config) *Handler {
return NewHandler(database.NewGormAdapter(db), modelregistry.NewModelRegistry(), cfg)
}
// NewHandlerWithBun creates a Handler backed by a Bun database connection.
func NewHandlerWithBun(db *bun.DB, cfg Config) *Handler {
return NewHandler(database.NewBunAdapter(db), modelregistry.NewModelRegistry(), cfg)
}
// NewHandlerWithDB creates a Handler using an existing common.Database and a new registry.
func NewHandlerWithDB(db common.Database, cfg Config) *Handler {
return NewHandler(db, modelregistry.NewModelRegistry(), cfg)
}
// SetupMuxRoutes mounts the MCP HTTP/SSE endpoints on the given Gorilla Mux router
// using the base path from Config.BasePath, behind Guard(securityList).
//
// Routes registered:
// - GET {basePath}/sse — SSE connection endpoint (client subscribes here)
// - POST {basePath}/message — JSON-RPC message endpoint (client sends requests here)
//
// Nothing is mounted (and an error is logged) when securityList has no provider.
func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, securityList *security.SecurityList) {
if !requireGuard("SetupMuxRoutes", securityList) {
return
}
mountMuxSSE(muxRouter, handler, handler.AuthedSSEServer(securityList))
}
// SetupMuxRoutesUnauthenticated is SetupMuxRoutes without the guard. Every caller reaches every
// registered model, so use it only behind another trusted layer. A warning is logged.
func SetupMuxRoutesUnauthenticated(muxRouter *mux.Router, handler *Handler) {
warnUnauthenticated("SetupMuxRoutesUnauthenticated")
mountMuxSSE(muxRouter, handler, handler.SSEServer())
}
func mountMuxSSE(muxRouter *mux.Router, handler *Handler, h http.Handler) {
basePath := handler.config.BasePath
muxRouter.Handle(basePath+"/sse", h).Methods("GET", "OPTIONS")
muxRouter.Handle(basePath+"/message", h).Methods("POST", "OPTIONS")
// Convenience: also expose the full SSE server at basePath for clients that
// use ServeHTTP directly (e.g. net/http default mux).
muxRouter.PathPrefix(basePath).Handler(http.StripPrefix(basePath, h))
}
// SetupBunRouterRoutes mounts the MCP HTTP/SSE endpoints on a bunrouter router
// using the base path from Config.BasePath, behind Guard(securityList).
//
// Routes registered:
// - GET {basePath}/sse — SSE connection endpoint
// - POST {basePath}/message — JSON-RPC message endpoint
func SetupBunRouterRoutes(router *bunrouter.Router, handler *Handler, securityList *security.SecurityList) {
if !requireGuard("SetupBunRouterRoutes", securityList) {
return
}
mountBunSSE(router, handler, handler.AuthedSSEServer(securityList))
}
// SetupBunRouterRoutesUnauthenticated is SetupBunRouterRoutes without the guard. A warning is logged.
func SetupBunRouterRoutesUnauthenticated(router *bunrouter.Router, handler *Handler) {
warnUnauthenticated("SetupBunRouterRoutesUnauthenticated")
mountBunSSE(router, handler, handler.SSEServer())
}
func mountBunSSE(router *bunrouter.Router, handler *Handler, h http.Handler) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic mounting resolvemcp bunrouter routes: %v\n%s", rec, debug.Stack())
}
}()
basePath := handler.config.BasePath
router.GET(basePath+"/sse", bunrouter.HTTPHandler(h))
logger.Info("Registered resolvemcp bunrouter route GET %s/sse", basePath)
router.POST(basePath+"/message", bunrouter.HTTPHandler(h))
logger.Info("Registered resolvemcp bunrouter route POST %s/message", basePath)
}
// NewSSEServer returns an http.Handler that serves MCP over SSE behind Guard(securityList).
// If Config.BasePath is set it is used directly; otherwise the base path is
// detected from each incoming request (by stripping the "/sse" or "/message" suffix).
//
// h := resolvemcp.NewSSEServer(handler, securityList)
// http.Handle("/api/mcp/", h)
func NewSSEServer(handler *Handler, securityList *security.SecurityList) http.Handler {
return handler.AuthedSSEServer(securityList)
}
// SetupMuxStreamableHTTPRoutes mounts the MCP streamable HTTP endpoint on the given Gorilla Mux
// router, behind Guard(securityList). The streamable HTTP transport uses a single endpoint
// (Config.BasePath) for all communication: POST for client→server messages, GET for
// server→client streaming.
//
// Nothing is mounted (and an error is logged) when securityList has no provider.
func SetupMuxStreamableHTTPRoutes(muxRouter *mux.Router, handler *Handler, securityList *security.SecurityList) {
if !requireGuard("SetupMuxStreamableHTTPRoutes", securityList) {
return
}
basePath := handler.config.BasePath
muxRouter.PathPrefix(basePath).Handler(http.StripPrefix(basePath, handler.AuthedStreamableHTTPServer(securityList)))
}
// SetupMuxStreamableHTTPRoutesUnauthenticated is SetupMuxStreamableHTTPRoutes without the guard.
// A warning is logged.
func SetupMuxStreamableHTTPRoutesUnauthenticated(muxRouter *mux.Router, handler *Handler) {
warnUnauthenticated("SetupMuxStreamableHTTPRoutesUnauthenticated")
basePath := handler.config.BasePath
muxRouter.PathPrefix(basePath).Handler(http.StripPrefix(basePath, handler.StreamableHTTPServer()))
}
// SetupBunRouterStreamableHTTPRoutes mounts the MCP streamable HTTP endpoint on a bunrouter
// router, behind Guard(securityList). The transport uses a single endpoint (Config.BasePath).
func SetupBunRouterStreamableHTTPRoutes(router *bunrouter.Router, handler *Handler, securityList *security.SecurityList) {
if !requireGuard("SetupBunRouterStreamableHTTPRoutes", securityList) {
return
}
mountBunStreamable(router, handler, handler.AuthedStreamableHTTPServer(securityList))
}
// SetupBunRouterStreamableHTTPRoutesUnauthenticated is SetupBunRouterStreamableHTTPRoutes
// without the guard. A warning is logged.
func SetupBunRouterStreamableHTTPRoutesUnauthenticated(router *bunrouter.Router, handler *Handler) {
warnUnauthenticated("SetupBunRouterStreamableHTTPRoutesUnauthenticated")
mountBunStreamable(router, handler, handler.StreamableHTTPServer())
}
func mountBunStreamable(router *bunrouter.Router, handler *Handler, h http.Handler) {
basePath := handler.config.BasePath
router.GET(basePath, bunrouter.HTTPHandler(h))
router.POST(basePath, bunrouter.HTTPHandler(h))
router.DELETE(basePath, bunrouter.HTTPHandler(h))
}
// NewStreamableHTTPHandler returns an http.Handler that serves MCP over the streamable HTTP
// transport behind Guard(securityList). Mount it at the desired path; that path becomes the
// MCP endpoint.
//
// h := resolvemcp.NewStreamableHTTPHandler(handler, securityList)
// http.Handle("/mcp", h)
// engine.Any("/mcp", gin.WrapH(h))
func NewStreamableHTTPHandler(handler *Handler, securityList *security.SecurityList) http.Handler {
return handler.AuthedStreamableHTTPServer(securityList)
}