diff --git a/pkg/resolvemcp/README.md b/pkg/resolvemcp/README.md index 904ff77..e093d57 100644 --- a/pkg/resolvemcp/README.md +++ b/pkg/resolvemcp/README.md @@ -11,7 +11,9 @@ import ( ) // 1. Create a handler -handler := resolvemcp.NewHandlerWithGORM(db) +handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{ + BaseURL: "http://localhost:8080", +}) // 2. Register models handler.RegisterModel("public", "users", &User{}) @@ -19,19 +21,35 @@ handler.RegisterModel("public", "orders", &Order{}) // 3. Mount routes r := mux.NewRouter() -resolvemcp.SetupMuxRoutes(r, handler, "http://localhost:8080") +resolvemcp.SetupMuxRoutes(r, handler) ``` --- +## Config + +```go +type Config struct { + // BaseURL is the public-facing base URL of the server (e.g. "http://localhost:8080"). + // Sent to MCP clients during the SSE handshake so they know where to POST messages. + // If empty, it is detected from each incoming request using the Host header and + // TLS state (X-Forwarded-Proto is honoured for reverse-proxy deployments). + BaseURL string + + // BasePath is the URL path prefix where MCP endpoints are mounted (e.g. "/mcp"). + // Required. + BasePath string +} +``` + ## Handler Creation | Function | Description | |---|---| -| `NewHandlerWithGORM(db *gorm.DB) *Handler` | Backed by GORM | -| `NewHandlerWithBun(db *bun.DB) *Handler` | Backed by Bun | -| `NewHandlerWithDB(db common.Database) *Handler` | Backed by any `common.Database` | -| `NewHandler(db common.Database, registry common.ModelRegistry) *Handler` | Full control over registry | +| `NewHandlerWithGORM(db *gorm.DB, cfg Config) *Handler` | Backed by GORM | +| `NewHandlerWithBun(db *bun.DB, cfg Config) *Handler` | Backed by Bun | +| `NewHandlerWithDB(db common.Database, cfg Config) *Handler` | Backed by any `common.Database` | +| `NewHandler(db common.Database, registry common.ModelRegistry, cfg Config) *Handler` | Full control over registry | --- @@ -53,40 +71,43 @@ Each call immediately creates four MCP **tools** and one MCP **resource** for th The `*server.SSEServer` returned by any of the helpers below implements `http.Handler`, so it works with every Go HTTP framework. +`Config.BasePath` is required and used for all route registration. +`Config.BaseURL` is optional — when empty it is detected from each request. + ### Gorilla Mux ```go -resolvemcp.SetupMuxRoutes(r, handler, "http://localhost:8080") +resolvemcp.SetupMuxRoutes(r, handler) ``` Registers: | Route | Method | Description | |---|---|---| -| `/mcp/sse` | GET | SSE connection — clients subscribe here | -| `/mcp/message` | POST | JSON-RPC — clients send requests here | -| `/mcp/*` | any | Full SSE server (convenience prefix) | +| `{BasePath}/sse` | GET | SSE connection — clients subscribe here | +| `{BasePath}/message` | POST | JSON-RPC — clients send requests here | +| `{BasePath}/*` | any | Full SSE server (convenience prefix) | ### bunrouter ```go -resolvemcp.SetupBunRouterRoutes(router, handler, "http://localhost:8080", "/mcp") +resolvemcp.SetupBunRouterRoutes(router, handler) ``` -Registers `GET /mcp/sse` and `POST /mcp/message` on the provided `*bunrouter.Router`. +Registers `GET {BasePath}/sse` and `POST {BasePath}/message` on the provided `*bunrouter.Router`. ### Gin (or any `http.Handler`-compatible framework) -Use `handler.SSEServer` to get a pre-bound `*server.SSEServer` and wrap it with the framework's adapter: +Use `handler.SSEServer()` to get an `http.Handler` and wrap it with the framework's adapter: ```go -sse := handler.SSEServer("http://localhost:8080", "/mcp") +sse := handler.SSEServer() // Gin engine.Any("/mcp/*path", gin.WrapH(sse)) // net/http -http.Handle("/mcp/", http.StripPrefix("/mcp", sse)) +http.Handle("/mcp/", sse) // Echo e.Any("/mcp/*", echo.WrapHandler(sse)) diff --git a/pkg/resolvemcp/handler.go b/pkg/resolvemcp/handler.go index d7a57b8..93f4758 100644 --- a/pkg/resolvemcp/handler.go +++ b/pkg/resolvemcp/handler.go @@ -5,8 +5,10 @@ import ( "database/sql" "encoding/json" "fmt" + "net/http" "reflect" "strings" + "sync" "github.com/mark3labs/mcp-go/server" @@ -21,17 +23,19 @@ type Handler struct { registry common.ModelRegistry hooks *HookRegistry mcpServer *server.MCPServer + config Config name string version string } -// NewHandler creates a Handler with the given database and model registry. -func NewHandler(db common.Database, registry common.ModelRegistry) *Handler { +// NewHandler creates a Handler with the given database, model registry, and config. +func NewHandler(db common.Database, registry common.ModelRegistry, cfg Config) *Handler { return &Handler{ db: db, registry: registry, hooks: NewHookRegistry(), mcpServer: server.NewMCPServer("resolvemcp", "1.0.0"), + config: cfg, name: "resolvemcp", version: "1.0.0", } @@ -52,12 +56,18 @@ func (h *Handler) MCPServer() *server.MCPServer { return h.mcpServer } -// SSEServer creates an *server.SSEServer bound to this handler. -// Use it to mount MCP on any HTTP framework that accepts http.Handler. -// -// sse := handler.SSEServer("http://localhost:8080", "/mcp") -// ginEngine.Any("/mcp/*path", gin.WrapH(sse)) -func (h *Handler) SSEServer(baseURL, basePath string) *server.SSEServer { +// SSEServer returns an http.Handler that serves MCP over SSE. +// Config.BasePath must be set. Config.BaseURL is used when set; if empty it is +// detected automatically from each incoming request. +func (h *Handler) SSEServer() http.Handler { + if h.config.BaseURL != "" { + return h.newSSEServer(h.config.BaseURL, h.config.BasePath) + } + return &dynamicSSEHandler{h: h} +} + +// newSSEServer creates a concrete *server.SSEServer for known baseURL and basePath values. +func (h *Handler) newSSEServer(baseURL, basePath string) *server.SSEServer { return server.NewSSEServer( h.mcpServer, server.WithBaseURL(baseURL), @@ -65,6 +75,44 @@ func (h *Handler) SSEServer(baseURL, basePath string) *server.SSEServer { ) } +// dynamicSSEHandler detects BaseURL from each request and delegates to a cached +// *server.SSEServer per detected baseURL. Used when Config.BaseURL is empty. +type dynamicSSEHandler struct { + h *Handler + mu sync.Mutex + pool map[string]*server.SSEServer +} + +func (d *dynamicSSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + baseURL := requestBaseURL(r) + + d.mu.Lock() + if d.pool == nil { + d.pool = make(map[string]*server.SSEServer) + } + s, ok := d.pool[baseURL] + if !ok { + s = d.h.newSSEServer(baseURL, d.h.config.BasePath) + d.pool[baseURL] = s + } + d.mu.Unlock() + + s.ServeHTTP(w, r) +} + +// requestBaseURL builds the base URL from an incoming request. +// It honours the X-Forwarded-Proto header for deployments behind a proxy. +func requestBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + return scheme + "://" + r.Host +} + // RegisterModel registers a model and immediately exposes it as MCP tools and a resource. func (h *Handler) RegisterModel(schema, entity string, model interface{}) error { fullName := buildModelName(schema, entity) diff --git a/pkg/resolvemcp/resolvemcp.go b/pkg/resolvemcp/resolvemcp.go index 51bafc1..41130f2 100644 --- a/pkg/resolvemcp/resolvemcp.go +++ b/pkg/resolvemcp/resolvemcp.go @@ -8,18 +8,17 @@ // // Usage: // -// handler := resolvemcp.NewHandlerWithGORM(db) +// handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{BaseURL: "http://localhost:8080"}) // handler.RegisterModel("public", "users", &User{}) // // r := mux.NewRouter() -// resolvemcp.SetupMuxRoutes(r, handler, "http://localhost:8080") +// resolvemcp.SetupMuxRoutes(r, handler) package resolvemcp import ( "net/http" "github.com/gorilla/mux" - "github.com/mark3labs/mcp-go/server" "github.com/uptrace/bun" bunrouter "github.com/uptrace/bunrouter" "gorm.io/gorm" @@ -29,72 +28,73 @@ import ( "github.com/bitechdev/ResolveSpec/pkg/modelregistry" ) +// 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 +} + // NewHandlerWithGORM creates a Handler backed by a GORM database connection. -func NewHandlerWithGORM(db *gorm.DB) *Handler { - return NewHandler(database.NewGormAdapter(db), modelregistry.NewModelRegistry()) +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) *Handler { - return NewHandler(database.NewBunAdapter(db), modelregistry.NewModelRegistry()) +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) *Handler { - return NewHandler(db, modelregistry.NewModelRegistry()) +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. -// -// 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. +// SetupMuxRoutes mounts the MCP HTTP/SSE endpoints on the given Gorilla Mux router +// using the base path from Config.BasePath (falls back to "/mcp" if empty). // // Two routes are registered: -// - GET /mcp/sse — SSE connection endpoint (client subscribes here) -// - POST /mcp/message — JSON-RPC message endpoint (client sends requests here) +// - GET {basePath}/sse — SSE connection endpoint (client subscribes here) +// - POST {basePath}/message — JSON-RPC message endpoint (client sends requests here) // // To protect these routes with authentication, wrap the mux router or apply middleware // before calling SetupMuxRoutes. -func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, baseURL string) { - sseServer := server.NewSSEServer( - handler.mcpServer, - server.WithBaseURL(baseURL), - server.WithBasePath("/mcp"), - ) +func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler) { + basePath := handler.config.BasePath + h := handler.SSEServer() - muxRouter.Handle("/mcp/sse", sseServer.SSEHandler()).Methods("GET", "OPTIONS") - muxRouter.Handle("/mcp/message", sseServer.MessageHandler()).Methods("POST", "OPTIONS") + muxRouter.Handle(basePath+"/sse", h).Methods("GET", "OPTIONS") + muxRouter.Handle(basePath+"/message", h).Methods("POST", "OPTIONS") - // Convenience: also expose the full SSE server at /mcp for clients that + // Convenience: also expose the full SSE server at basePath for clients that // use ServeHTTP directly (e.g. net/http default mux). - muxRouter.PathPrefix("/mcp").Handler(http.StripPrefix("/mcp", sseServer)) + muxRouter.PathPrefix(basePath).Handler(http.StripPrefix(basePath, h)) } -// NewSSEServer creates an *server.SSEServer that can be mounted manually, -// useful when integrating with non-Mux routers or adding extra middleware. +// SetupBunRouterRoutes mounts the MCP HTTP/SSE endpoints on a bunrouter router +// using the base path from Config.BasePath. // -// sseServer := resolvemcp.NewSSEServer(handler, "http://localhost:8080", "/mcp") -// http.Handle("/mcp/", http.StripPrefix("/mcp", sseServer)) -func NewSSEServer(handler *Handler, baseURL, basePath string) *server.SSEServer { - return server.NewSSEServer( - handler.mcpServer, - server.WithBaseURL(baseURL), - server.WithBasePath(basePath), - ) -} - -// SetupBunRouterRoutes mounts the MCP HTTP/SSE endpoints on a bunrouter router. -// -// Two routes are registered under the given basePath prefix: +// Two routes are registered: // - GET {basePath}/sse — SSE connection endpoint // - POST {basePath}/message — JSON-RPC message endpoint -func SetupBunRouterRoutes(router *bunrouter.Router, handler *Handler, baseURL, basePath string) { - sseServer := server.NewSSEServer( - handler.mcpServer, - server.WithBaseURL(baseURL), - server.WithBasePath(basePath), - ) +func SetupBunRouterRoutes(router *bunrouter.Router, handler *Handler) { + basePath := handler.config.BasePath + h := handler.SSEServer() - router.GET(basePath+"/sse", bunrouter.HTTPHandler(sseServer.SSEHandler())) - router.POST(basePath+"/message", bunrouter.HTTPHandler(sseServer.MessageHandler())) + router.GET(basePath+"/sse", bunrouter.HTTPHandler(h)) + router.POST(basePath+"/message", bunrouter.HTTPHandler(h)) +} + +// NewSSEServer returns an http.Handler that serves MCP over SSE. +// 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) +// http.Handle("/api/mcp/", h) +func NewSSEServer(handler *Handler) http.Handler { + return handler.SSEServer() }