From 4adf94fe373662a28dbf32f5325aac0ce29249d5 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 7 Apr 2026 19:09:51 +0200 Subject: [PATCH 1/4] feat(go.mod): add mcp-go dependency for enhanced functionality --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index f1e3fdd..32fbbe7 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.8.0 github.com/klauspost/compress v1.18.2 + github.com/mark3labs/mcp-go v0.46.0 github.com/mattn/go-sqlite3 v1.14.33 github.com/microsoft/go-mssqldb v1.9.5 github.com/mochi-mqtt/server/v2 v2.7.9 @@ -88,7 +89,6 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mark3labs/mcp-go v0.46.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect From c2e2c9b87369f8c80f650c1c15773fa18d228f3e Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 7 Apr 2026 19:52:38 +0200 Subject: [PATCH 2/4] feat(transport): add streamable HTTP transport for MCP --- pkg/resolvemcp/README.md | 71 +++++++++++++++++++++++++----------- pkg/resolvemcp/handler.go | 8 ++++ pkg/resolvemcp/resolvemcp.go | 33 +++++++++++++++++ 3 files changed, 91 insertions(+), 21 deletions(-) diff --git a/pkg/resolvemcp/README.md b/pkg/resolvemcp/README.md index 7bb6429..68399c8 100644 --- a/pkg/resolvemcp/README.md +++ b/pkg/resolvemcp/README.md @@ -67,52 +67,81 @@ Each call immediately creates four MCP **tools** and one MCP **resource** for th --- -## HTTP / SSE Transport - -The `*server.SSEServer` returned by any of the helpers below implements `http.Handler`, so it works with every Go HTTP framework. +## HTTP Transports `Config.BasePath` is required and used for all route registration. `Config.BaseURL` is optional — when empty it is detected from each request. -### Gorilla Mux +Two transports are supported: **SSE** (legacy, two-endpoint) and **Streamable HTTP** (recommended, single-endpoint). + +--- + +### SSE Transport + +Two endpoints: `GET {BasePath}/sse` (subscribe) + `POST {BasePath}/message` (send). + +#### Gorilla Mux ```go resolvemcp.SetupMuxRoutes(r, handler) ``` -Registers: - | Route | Method | Description | |---|---|---| | `{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 +#### bunrouter ```go resolvemcp.SetupBunRouterRoutes(router, handler) ``` -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 an `http.Handler` and wrap it with the framework's adapter: +#### Gin / net/http / Echo ```go sse := handler.SSEServer() -// Gin -engine.Any("/mcp/*path", gin.WrapH(sse)) - -// net/http -http.Handle("/mcp/", sse) - -// Echo -e.Any("/mcp/*", echo.WrapHandler(sse)) +engine.Any("/mcp/*path", gin.WrapH(sse)) // Gin +http.Handle("/mcp/", sse) // net/http +e.Any("/mcp/*", echo.WrapHandler(sse)) // Echo ``` +--- + +### Streamable HTTP Transport + +Single endpoint at `{BasePath}`. Handles POST (client→server) and GET (server→client streaming). Preferred for new integrations. + +#### Gorilla Mux + +```go +resolvemcp.SetupMuxStreamableHTTPRoutes(r, handler) +``` + +Mounts the handler at `{BasePath}` (all methods). + +#### bunrouter + +```go +resolvemcp.SetupBunRouterStreamableHTTPRoutes(router, handler) +``` + +Registers GET, POST, DELETE on `{BasePath}`. + +#### Gin / net/http / Echo + +```go +h := handler.StreamableHTTPServer() +// or: h := resolvemcp.NewStreamableHTTPHandler(handler) + +engine.Any("/mcp", gin.WrapH(h)) // Gin +http.Handle("/mcp", h) // net/http +e.Any("/mcp", echo.WrapHandler(h)) // Echo +``` + +--- + ### Authentication Add middleware before the MCP routes. The handler itself has no auth layer. diff --git a/pkg/resolvemcp/handler.go b/pkg/resolvemcp/handler.go index cf52414..af33862 100644 --- a/pkg/resolvemcp/handler.go +++ b/pkg/resolvemcp/handler.go @@ -69,6 +69,14 @@ func (h *Handler) SSEServer() http.Handler { return &dynamicSSEHandler{h: h} } +// StreamableHTTPServer returns an http.Handler that serves MCP over the streamable HTTP transport. +// Unlike SSE (which requires two endpoints), streamable HTTP uses a single endpoint for all +// client-server communication (POST for requests, GET for server-initiated messages). +// Mount the returned handler at the desired path; the path itself becomes the MCP endpoint. +func (h *Handler) StreamableHTTPServer() http.Handler { + return server.NewStreamableHTTPServer(h.mcpServer) +} + // newSSEServer creates a concrete *server.SSEServer for known baseURL and basePath values. func (h *Handler) newSSEServer(baseURL, basePath string) *server.SSEServer { return server.NewSSEServer( diff --git a/pkg/resolvemcp/resolvemcp.go b/pkg/resolvemcp/resolvemcp.go index 41130f2..534d8e0 100644 --- a/pkg/resolvemcp/resolvemcp.go +++ b/pkg/resolvemcp/resolvemcp.go @@ -98,3 +98,36 @@ func SetupBunRouterRoutes(router *bunrouter.Router, handler *Handler) { func NewSSEServer(handler *Handler) http.Handler { return handler.SSEServer() } + +// SetupMuxStreamableHTTPRoutes mounts the MCP streamable HTTP endpoint on the given Gorilla Mux router. +// The streamable HTTP transport uses a single endpoint (Config.BasePath) for all communication: +// POST for client→server messages, GET for server→client streaming. +// +// Example: +// +// resolvemcp.SetupMuxStreamableHTTPRoutes(r, handler) // mounts at Config.BasePath +func SetupMuxStreamableHTTPRoutes(muxRouter *mux.Router, handler *Handler) { + basePath := handler.config.BasePath + h := handler.StreamableHTTPServer() + muxRouter.PathPrefix(basePath).Handler(http.StripPrefix(basePath, h)) +} + +// SetupBunRouterStreamableHTTPRoutes mounts the MCP streamable HTTP endpoint on a bunrouter router. +// The streamable HTTP transport uses a single endpoint (Config.BasePath). +func SetupBunRouterStreamableHTTPRoutes(router *bunrouter.Router, handler *Handler) { + basePath := handler.config.BasePath + h := handler.StreamableHTTPServer() + 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. +// Mount it at the desired path; that path becomes the MCP endpoint. +// +// h := resolvemcp.NewStreamableHTTPHandler(handler) +// http.Handle("/mcp", h) +// engine.Any("/mcp", gin.WrapH(h)) +func NewStreamableHTTPHandler(handler *Handler) http.Handler { + return handler.StreamableHTTPServer() +} From ea5bb38ee45bc09e2eee0240c62a21d005eb2e39 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 7 Apr 2026 20:03:43 +0200 Subject: [PATCH 3/4] feat(handler): update to use static base path for SSE server --- pkg/resolvemcp/handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/resolvemcp/handler.go b/pkg/resolvemcp/handler.go index af33862..579e215 100644 --- a/pkg/resolvemcp/handler.go +++ b/pkg/resolvemcp/handler.go @@ -82,7 +82,7 @@ func (h *Handler) newSSEServer(baseURL, basePath string) *server.SSEServer { return server.NewSSEServer( h.mcpServer, server.WithBaseURL(baseURL), - server.WithBasePath(basePath), + server.WithStaticBasePath(basePath), ) } From aa095d6bfdfc66129ecd6c1b888c62d3457efdc4 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 7 Apr 2026 20:38:22 +0200 Subject: [PATCH 4/4] fix(tests): replace panic with log.Fatal for better error handling --- .../postgres_listener_example_test.go | 25 ++++----- pkg/resolvemcp/handler.go | 42 ++++++++++----- pkg/server/example_test.go | 51 ++++++++++--------- 3 files changed, 67 insertions(+), 51 deletions(-) diff --git a/pkg/dbmanager/providers/postgres_listener_example_test.go b/pkg/dbmanager/providers/postgres_listener_example_test.go index 03f0452..bd979ee 100644 --- a/pkg/dbmanager/providers/postgres_listener_example_test.go +++ b/pkg/dbmanager/providers/postgres_listener_example_test.go @@ -3,6 +3,7 @@ package providers_test import ( "context" "fmt" + "log" "time" "github.com/bitechdev/ResolveSpec/pkg/dbmanager" @@ -29,14 +30,14 @@ func ExamplePostgresListener_basic() { ctx := context.Background() if err := provider.Connect(ctx, cfg); err != nil { - panic(fmt.Sprintf("Failed to connect: %v", err)) + log.Fatalf("Failed to connect: %v", err) } defer provider.Close() // Get listener listener, err := provider.GetListener(ctx) if err != nil { - panic(fmt.Sprintf("Failed to get listener: %v", err)) + log.Fatalf("Failed to get listener: %v", err) } // Subscribe to a channel with a handler @@ -44,13 +45,13 @@ func ExamplePostgresListener_basic() { fmt.Printf("Received notification on %s: %s\n", channel, payload) }) if err != nil { - panic(fmt.Sprintf("Failed to listen: %v", err)) + log.Fatalf("Failed to listen: %v", err) } // Send a notification err = listener.Notify(ctx, "user_events", `{"event":"user_created","user_id":123}`) if err != nil { - panic(fmt.Sprintf("Failed to notify: %v", err)) + log.Fatalf("Failed to notify: %v", err) } // Wait for notification to be processed @@ -58,7 +59,7 @@ func ExamplePostgresListener_basic() { // Unsubscribe from the channel if err := listener.Unlisten("user_events"); err != nil { - panic(fmt.Sprintf("Failed to unlisten: %v", err)) + log.Fatalf("Failed to unlisten: %v", err) } } @@ -80,13 +81,13 @@ func ExamplePostgresListener_multipleChannels() { ctx := context.Background() if err := provider.Connect(ctx, cfg); err != nil { - panic(fmt.Sprintf("Failed to connect: %v", err)) + log.Fatalf("Failed to connect: %v", err) } defer provider.Close() listener, err := provider.GetListener(ctx) if err != nil { - panic(fmt.Sprintf("Failed to get listener: %v", err)) + log.Fatalf("Failed to get listener: %v", err) } // Listen to multiple channels @@ -97,7 +98,7 @@ func ExamplePostgresListener_multipleChannels() { fmt.Printf("[%s] %s\n", ch, payload) }) if err != nil { - panic(fmt.Sprintf("Failed to listen on %s: %v", channel, err)) + log.Fatalf("Failed to listen on %s: %v", channel, err) } } @@ -140,14 +141,14 @@ func ExamplePostgresListener_withDBManager() { provider := providers.NewPostgresProvider() if err := provider.Connect(ctx, cfg); err != nil { - panic(err) + log.Fatal(err) } defer provider.Close() // Get listener listener, err := provider.GetListener(ctx) if err != nil { - panic(err) + log.Fatal(err) } // Subscribe to application events @@ -186,13 +187,13 @@ func ExamplePostgresListener_errorHandling() { ctx := context.Background() if err := provider.Connect(ctx, cfg); err != nil { - panic(fmt.Sprintf("Failed to connect: %v", err)) + log.Fatalf("Failed to connect: %v", err) } defer provider.Close() listener, err := provider.GetListener(ctx) if err != nil { - panic(fmt.Sprintf("Failed to get listener: %v", err)) + log.Fatalf("Failed to get listener: %v", err) } // The listener automatically reconnects if the connection is lost diff --git a/pkg/resolvemcp/handler.go b/pkg/resolvemcp/handler.go index 579e215..4ed7c9c 100644 --- a/pkg/resolvemcp/handler.go +++ b/pkg/resolvemcp/handler.go @@ -197,8 +197,19 @@ func (h *Handler) getSchemaAndTable(defaultSchema, entity string, model interfac return defaultSchema, entity } +// recoverPanic catches a panic from the current goroutine and returns it as an error. +// Usage: defer recoverPanic(&returnedErr) +func recoverPanic(err *error) { + if r := recover(); r != nil { + msg := fmt.Sprintf("%v", r) + logger.Error("[resolvemcp] panic recovered: %s", msg) + *err = fmt.Errorf("internal error: %s", msg) + } +} + // executeRead reads records from the database and returns raw data + metadata. -func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, options common.RequestOptions) (interface{}, *common.Metadata, error) { +func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, options common.RequestOptions) (_ interface{}, _ *common.Metadata, retErr error) { + defer recoverPanic(&retErr) model, err := h.registry.GetModelByEntity(schema, entity) if err != nil { return nil, nil, fmt.Errorf("model not found: %w", err) @@ -254,15 +265,6 @@ func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, op query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", cu.Expression, cu.Name)) } - // Preloads - if len(options.Preload) > 0 { - var err error - query, err = h.applyPreloads(model, query, options.Preload) - if err != nil { - return nil, nil, fmt.Errorf("failed to apply preloads: %w", err) - } - } - // Filters query = h.applyFilters(query, options.Filters) @@ -304,7 +306,7 @@ func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, op } } - // Count + // Count — must happen before preloads are applied; Bun panics when counting with relations. total, err := query.Count(ctx) if err != nil { return nil, nil, fmt.Errorf("error counting records: %w", err) @@ -318,6 +320,15 @@ func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, op query = query.Offset(*options.Offset) } + // Preloads — applied after count to avoid Bun panic when counting with relations. + if len(options.Preload) > 0 { + var preloadErr error + query, preloadErr = h.applyPreloads(model, query, options.Preload) + if preloadErr != nil { + return nil, nil, fmt.Errorf("failed to apply preloads: %w", preloadErr) + } + } + // BeforeRead hook hookCtx.Query = query if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil { @@ -378,7 +389,8 @@ func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, op } // executeCreate inserts one or more records. -func (h *Handler) executeCreate(ctx context.Context, schema, entity string, data interface{}) (interface{}, error) { +func (h *Handler) executeCreate(ctx context.Context, schema, entity string, data interface{}) (_ interface{}, retErr error) { + defer recoverPanic(&retErr) model, err := h.registry.GetModelByEntity(schema, entity) if err != nil { return nil, fmt.Errorf("model not found: %w", err) @@ -462,7 +474,8 @@ func (h *Handler) executeCreate(ctx context.Context, schema, entity string, data } // executeUpdate updates a record by ID. -func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string, data interface{}) (interface{}, error) { +func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string, data interface{}) (_ interface{}, retErr error) { + defer recoverPanic(&retErr) model, err := h.registry.GetModelByEntity(schema, entity) if err != nil { return nil, fmt.Errorf("model not found: %w", err) @@ -572,7 +585,8 @@ func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string, } // executeDelete deletes a record by ID. -func (h *Handler) executeDelete(ctx context.Context, schema, entity, id string) (interface{}, error) { +func (h *Handler) executeDelete(ctx context.Context, schema, entity, id string) (_ interface{}, retErr error) { + defer recoverPanic(&retErr) if id == "" { return nil, fmt.Errorf("delete requires an ID") } diff --git a/pkg/server/example_test.go b/pkg/server/example_test.go index 032b6f0..ec71502 100644 --- a/pkg/server/example_test.go +++ b/pkg/server/example_test.go @@ -3,6 +3,7 @@ package server_test import ( "context" "fmt" + "log" "net/http" "time" @@ -29,18 +30,18 @@ func ExampleManager_basic() { GZIP: true, // Enable GZIP compression }) if err != nil { - panic(err) + log.Fatal(err) } // Start all servers if err := mgr.StartAll(); err != nil { - panic(err) + log.Fatal(err) } // Server is now running... // When done, stop gracefully if err := mgr.StopAll(); err != nil { - panic(err) + log.Fatal(err) } } @@ -61,7 +62,7 @@ func ExampleManager_https() { SSLKey: "/path/to/key.pem", }) if err != nil { - panic(err) + log.Fatal(err) } // Option 2: Self-signed certificate (for development) @@ -73,27 +74,27 @@ func ExampleManager_https() { SelfSignedSSL: true, }) if err != nil { - panic(err) + log.Fatal(err) } // Option 3: Let's Encrypt / AutoTLS (for production) _, err = mgr.Add(server.Config{ - Name: "https-server-letsencrypt", - Host: "0.0.0.0", - Port: 443, - Handler: handler, - AutoTLS: true, - AutoTLSDomains: []string{"example.com", "www.example.com"}, - AutoTLSEmail: "admin@example.com", + Name: "https-server-letsencrypt", + Host: "0.0.0.0", + Port: 443, + Handler: handler, + AutoTLS: true, + AutoTLSDomains: []string{"example.com", "www.example.com"}, + AutoTLSEmail: "admin@example.com", AutoTLSCacheDir: "./certs-cache", }) if err != nil { - panic(err) + log.Fatal(err) } // Start all servers if err := mgr.StartAll(); err != nil { - panic(err) + log.Fatal(err) } // Cleanup @@ -136,7 +137,7 @@ func ExampleManager_gracefulShutdown() { IdleTimeout: 120 * time.Second, }) if err != nil { - panic(err) + log.Fatal(err) } // Start servers and block until shutdown signal (SIGINT/SIGTERM) @@ -164,7 +165,7 @@ func ExampleManager_healthChecks() { Handler: mux, }) if err != nil { - panic(err) + log.Fatal(err) } // Add health and readiness endpoints @@ -173,7 +174,7 @@ func ExampleManager_healthChecks() { // Start the server if err := mgr.StartAll(); err != nil { - panic(err) + log.Fatal(err) } // Health check returns: @@ -204,7 +205,7 @@ func ExampleManager_multipleServers() { GZIP: true, }) if err != nil { - panic(err) + log.Fatal(err) } // Admin API server (different port) @@ -218,7 +219,7 @@ func ExampleManager_multipleServers() { Handler: adminHandler, }) if err != nil { - panic(err) + log.Fatal(err) } // Metrics server (internal only) @@ -232,18 +233,18 @@ func ExampleManager_multipleServers() { Handler: metricsHandler, }) if err != nil { - panic(err) + log.Fatal(err) } // Start all servers at once if err := mgr.StartAll(); err != nil { - panic(err) + log.Fatal(err) } // Get specific server instance publicInstance, err := mgr.Get("public-api") if err != nil { - panic(err) + log.Fatal(err) } fmt.Printf("Public API running on: %s\n", publicInstance.Addr()) @@ -253,7 +254,7 @@ func ExampleManager_multipleServers() { // Stop all servers gracefully (in parallel) if err := mgr.StopAll(); err != nil { - panic(err) + log.Fatal(err) } } @@ -273,11 +274,11 @@ func ExampleManager_monitoring() { Handler: handler, }) if err != nil { - panic(err) + log.Fatal(err) } if err := mgr.StartAll(); err != nil { - panic(err) + log.Fatal(err) } // Check server status