Compare commits

...

4 Commits

Author SHA1 Message Date
aa095d6bfd fix(tests): replace panic with log.Fatal for better error handling
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -29m52s
Build , Vet Test, and Lint / Build (push) Successful in -29m52s
Tests / Integration Tests (push) Failing after -30m46s
Tests / Unit Tests (push) Successful in -28m51s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -30m17s
Build , Vet Test, and Lint / Lint Code (push) Failing after -29m23s
2026-04-07 20:38:22 +02:00
ea5bb38ee4 feat(handler): update to use static base path for SSE server 2026-04-07 20:03:43 +02:00
c2e2c9b873 feat(transport): add streamable HTTP transport for MCP 2026-04-07 19:52:38 +02:00
4adf94fe37 feat(go.mod): add mcp-go dependency for enhanced functionality 2026-04-07 19:09:51 +02:00
6 changed files with 160 additions and 74 deletions

2
go.mod
View File

@@ -15,6 +15,7 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.8.0 github.com/jackc/pgx/v5 v5.8.0
github.com/klauspost/compress v1.18.2 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/mattn/go-sqlite3 v1.14.33
github.com/microsoft/go-mssqldb v1.9.5 github.com/microsoft/go-mssqldb v1.9.5
github.com/mochi-mqtt/server/v2 v2.7.9 github.com/mochi-mqtt/server/v2 v2.7.9
@@ -88,7 +89,6 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // 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/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect github.com/moby/go-archive v0.1.0 // indirect

View File

@@ -3,6 +3,7 @@ package providers_test
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"time" "time"
"github.com/bitechdev/ResolveSpec/pkg/dbmanager" "github.com/bitechdev/ResolveSpec/pkg/dbmanager"
@@ -29,14 +30,14 @@ func ExamplePostgresListener_basic() {
ctx := context.Background() ctx := context.Background()
if err := provider.Connect(ctx, cfg); err != nil { 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() defer provider.Close()
// Get listener // Get listener
listener, err := provider.GetListener(ctx) listener, err := provider.GetListener(ctx)
if err != nil { 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 // Subscribe to a channel with a handler
@@ -44,13 +45,13 @@ func ExamplePostgresListener_basic() {
fmt.Printf("Received notification on %s: %s\n", channel, payload) fmt.Printf("Received notification on %s: %s\n", channel, payload)
}) })
if err != nil { if err != nil {
panic(fmt.Sprintf("Failed to listen: %v", err)) log.Fatalf("Failed to listen: %v", err)
} }
// Send a notification // Send a notification
err = listener.Notify(ctx, "user_events", `{"event":"user_created","user_id":123}`) err = listener.Notify(ctx, "user_events", `{"event":"user_created","user_id":123}`)
if err != nil { if err != nil {
panic(fmt.Sprintf("Failed to notify: %v", err)) log.Fatalf("Failed to notify: %v", err)
} }
// Wait for notification to be processed // Wait for notification to be processed
@@ -58,7 +59,7 @@ func ExamplePostgresListener_basic() {
// Unsubscribe from the channel // Unsubscribe from the channel
if err := listener.Unlisten("user_events"); err != nil { 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() ctx := context.Background()
if err := provider.Connect(ctx, cfg); err != nil { 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() defer provider.Close()
listener, err := provider.GetListener(ctx) listener, err := provider.GetListener(ctx)
if err != nil { if err != nil {
panic(fmt.Sprintf("Failed to get listener: %v", err)) log.Fatalf("Failed to get listener: %v", err)
} }
// Listen to multiple channels // Listen to multiple channels
@@ -97,7 +98,7 @@ func ExamplePostgresListener_multipleChannels() {
fmt.Printf("[%s] %s\n", ch, payload) fmt.Printf("[%s] %s\n", ch, payload)
}) })
if err != nil { 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() provider := providers.NewPostgresProvider()
if err := provider.Connect(ctx, cfg); err != nil { if err := provider.Connect(ctx, cfg); err != nil {
panic(err) log.Fatal(err)
} }
defer provider.Close() defer provider.Close()
// Get listener // Get listener
listener, err := provider.GetListener(ctx) listener, err := provider.GetListener(ctx)
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
// Subscribe to application events // Subscribe to application events
@@ -186,13 +187,13 @@ func ExamplePostgresListener_errorHandling() {
ctx := context.Background() ctx := context.Background()
if err := provider.Connect(ctx, cfg); err != nil { 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() defer provider.Close()
listener, err := provider.GetListener(ctx) listener, err := provider.GetListener(ctx)
if err != nil { 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 // The listener automatically reconnects if the connection is lost

View File

@@ -67,52 +67,81 @@ Each call immediately creates four MCP **tools** and one MCP **resource** for th
--- ---
## HTTP / SSE Transport ## HTTP Transports
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.BasePath` is required and used for all route registration.
`Config.BaseURL` is optional — when empty it is detected from each request. `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 ```go
resolvemcp.SetupMuxRoutes(r, handler) resolvemcp.SetupMuxRoutes(r, handler)
``` ```
Registers:
| Route | Method | Description | | Route | Method | Description |
|---|---|---| |---|---|---|
| `{BasePath}/sse` | GET | SSE connection — clients subscribe here | | `{BasePath}/sse` | GET | SSE connection — clients subscribe here |
| `{BasePath}/message` | POST | JSON-RPC — clients send requests here | | `{BasePath}/message` | POST | JSON-RPC — clients send requests here |
| `{BasePath}/*` | any | Full SSE server (convenience prefix) |
### bunrouter #### bunrouter
```go ```go
resolvemcp.SetupBunRouterRoutes(router, handler) resolvemcp.SetupBunRouterRoutes(router, handler)
``` ```
Registers `GET {BasePath}/sse` and `POST {BasePath}/message` on the provided `*bunrouter.Router`. #### Gin / net/http / Echo
### Gin (or any `http.Handler`-compatible framework)
Use `handler.SSEServer()` to get an `http.Handler` and wrap it with the framework's adapter:
```go ```go
sse := handler.SSEServer() sse := handler.SSEServer()
// Gin engine.Any("/mcp/*path", gin.WrapH(sse)) // Gin
engine.Any("/mcp/*path", gin.WrapH(sse)) http.Handle("/mcp/", sse) // net/http
e.Any("/mcp/*", echo.WrapHandler(sse)) // Echo
// net/http
http.Handle("/mcp/", sse)
// Echo
e.Any("/mcp/*", echo.WrapHandler(sse))
``` ```
---
### 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 ### Authentication
Add middleware before the MCP routes. The handler itself has no auth layer. Add middleware before the MCP routes. The handler itself has no auth layer.

View File

@@ -69,12 +69,20 @@ func (h *Handler) SSEServer() http.Handler {
return &dynamicSSEHandler{h: h} 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. // newSSEServer creates a concrete *server.SSEServer for known baseURL and basePath values.
func (h *Handler) newSSEServer(baseURL, basePath string) *server.SSEServer { func (h *Handler) newSSEServer(baseURL, basePath string) *server.SSEServer {
return server.NewSSEServer( return server.NewSSEServer(
h.mcpServer, h.mcpServer,
server.WithBaseURL(baseURL), server.WithBaseURL(baseURL),
server.WithBasePath(basePath), server.WithStaticBasePath(basePath),
) )
} }
@@ -189,8 +197,19 @@ func (h *Handler) getSchemaAndTable(defaultSchema, entity string, model interfac
return defaultSchema, entity 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. // 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) model, err := h.registry.GetModelByEntity(schema, entity)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("model not found: %w", err) return nil, nil, fmt.Errorf("model not found: %w", err)
@@ -246,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)) 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 // Filters
query = h.applyFilters(query, options.Filters) query = h.applyFilters(query, options.Filters)
@@ -296,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) total, err := query.Count(ctx)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("error counting records: %w", err) return nil, nil, fmt.Errorf("error counting records: %w", err)
@@ -310,6 +320,15 @@ func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, op
query = query.Offset(*options.Offset) 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 // BeforeRead hook
hookCtx.Query = query hookCtx.Query = query
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil { if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
@@ -370,7 +389,8 @@ func (h *Handler) executeRead(ctx context.Context, schema, entity, id string, op
} }
// executeCreate inserts one or more records. // 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) model, err := h.registry.GetModelByEntity(schema, entity)
if err != nil { if err != nil {
return nil, fmt.Errorf("model not found: %w", err) return nil, fmt.Errorf("model not found: %w", err)
@@ -454,7 +474,8 @@ func (h *Handler) executeCreate(ctx context.Context, schema, entity string, data
} }
// executeUpdate updates a record by ID. // 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) model, err := h.registry.GetModelByEntity(schema, entity)
if err != nil { if err != nil {
return nil, fmt.Errorf("model not found: %w", err) return nil, fmt.Errorf("model not found: %w", err)
@@ -564,7 +585,8 @@ func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string,
} }
// executeDelete deletes a record by ID. // 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 == "" { if id == "" {
return nil, fmt.Errorf("delete requires an ID") return nil, fmt.Errorf("delete requires an ID")
} }

View File

@@ -98,3 +98,36 @@ func SetupBunRouterRoutes(router *bunrouter.Router, handler *Handler) {
func NewSSEServer(handler *Handler) http.Handler { func NewSSEServer(handler *Handler) http.Handler {
return handler.SSEServer() 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()
}

View File

@@ -3,6 +3,7 @@ package server_test
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"net/http" "net/http"
"time" "time"
@@ -29,18 +30,18 @@ func ExampleManager_basic() {
GZIP: true, // Enable GZIP compression GZIP: true, // Enable GZIP compression
}) })
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
// Start all servers // Start all servers
if err := mgr.StartAll(); err != nil { if err := mgr.StartAll(); err != nil {
panic(err) log.Fatal(err)
} }
// Server is now running... // Server is now running...
// When done, stop gracefully // When done, stop gracefully
if err := mgr.StopAll(); err != nil { if err := mgr.StopAll(); err != nil {
panic(err) log.Fatal(err)
} }
} }
@@ -61,7 +62,7 @@ func ExampleManager_https() {
SSLKey: "/path/to/key.pem", SSLKey: "/path/to/key.pem",
}) })
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
// Option 2: Self-signed certificate (for development) // Option 2: Self-signed certificate (for development)
@@ -73,7 +74,7 @@ func ExampleManager_https() {
SelfSignedSSL: true, SelfSignedSSL: true,
}) })
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
// Option 3: Let's Encrypt / AutoTLS (for production) // Option 3: Let's Encrypt / AutoTLS (for production)
@@ -88,12 +89,12 @@ func ExampleManager_https() {
AutoTLSCacheDir: "./certs-cache", AutoTLSCacheDir: "./certs-cache",
}) })
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
// Start all servers // Start all servers
if err := mgr.StartAll(); err != nil { if err := mgr.StartAll(); err != nil {
panic(err) log.Fatal(err)
} }
// Cleanup // Cleanup
@@ -136,7 +137,7 @@ func ExampleManager_gracefulShutdown() {
IdleTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second,
}) })
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
// Start servers and block until shutdown signal (SIGINT/SIGTERM) // Start servers and block until shutdown signal (SIGINT/SIGTERM)
@@ -164,7 +165,7 @@ func ExampleManager_healthChecks() {
Handler: mux, Handler: mux,
}) })
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
// Add health and readiness endpoints // Add health and readiness endpoints
@@ -173,7 +174,7 @@ func ExampleManager_healthChecks() {
// Start the server // Start the server
if err := mgr.StartAll(); err != nil { if err := mgr.StartAll(); err != nil {
panic(err) log.Fatal(err)
} }
// Health check returns: // Health check returns:
@@ -204,7 +205,7 @@ func ExampleManager_multipleServers() {
GZIP: true, GZIP: true,
}) })
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
// Admin API server (different port) // Admin API server (different port)
@@ -218,7 +219,7 @@ func ExampleManager_multipleServers() {
Handler: adminHandler, Handler: adminHandler,
}) })
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
// Metrics server (internal only) // Metrics server (internal only)
@@ -232,18 +233,18 @@ func ExampleManager_multipleServers() {
Handler: metricsHandler, Handler: metricsHandler,
}) })
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
// Start all servers at once // Start all servers at once
if err := mgr.StartAll(); err != nil { if err := mgr.StartAll(); err != nil {
panic(err) log.Fatal(err)
} }
// Get specific server instance // Get specific server instance
publicInstance, err := mgr.Get("public-api") publicInstance, err := mgr.Get("public-api")
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
fmt.Printf("Public API running on: %s\n", publicInstance.Addr()) fmt.Printf("Public API running on: %s\n", publicInstance.Addr())
@@ -253,7 +254,7 @@ func ExampleManager_multipleServers() {
// Stop all servers gracefully (in parallel) // Stop all servers gracefully (in parallel)
if err := mgr.StopAll(); err != nil { if err := mgr.StopAll(); err != nil {
panic(err) log.Fatal(err)
} }
} }
@@ -273,11 +274,11 @@ func ExampleManager_monitoring() {
Handler: handler, Handler: handler,
}) })
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
if err := mgr.StartAll(); err != nil { if err := mgr.StartAll(); err != nil {
panic(err) log.Fatal(err)
} }
// Check server status // Check server status