From 200a03c22593696473b4bf5cb2babeb70e006af4 Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 27 Mar 2026 13:28:03 +0200 Subject: [PATCH] feat(resolvemcp): add SSE server and bunrouter setup functions * Introduce SSEServer method for creating an SSE server bound to the handler. * Add SetupBunRouterRoutes function to mount MCP HTTP/SSE endpoints on bunrouter. * Update README with usage examples for new features. --- pkg/resolvemcp/README.md | 386 +++++++++++++++++++++++++++++++++++ pkg/resolvemcp/handler.go | 13 ++ pkg/resolvemcp/resolvemcp.go | 17 ++ 3 files changed, 416 insertions(+) create mode 100644 pkg/resolvemcp/README.md diff --git a/pkg/resolvemcp/README.md b/pkg/resolvemcp/README.md new file mode 100644 index 0000000..904ff77 --- /dev/null +++ b/pkg/resolvemcp/README.md @@ -0,0 +1,386 @@ +# resolvemcp + +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/pagination/preload options, same lifecycle hook system. + +## Quick Start + +```go +import ( + "github.com/bitechdev/ResolveSpec/pkg/resolvemcp" + "github.com/gorilla/mux" +) + +// 1. Create a handler +handler := resolvemcp.NewHandlerWithGORM(db) + +// 2. Register models +handler.RegisterModel("public", "users", &User{}) +handler.RegisterModel("public", "orders", &Order{}) + +// 3. Mount routes +r := mux.NewRouter() +resolvemcp.SetupMuxRoutes(r, handler, "http://localhost:8080") +``` + +--- + +## 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 | + +--- + +## Registering Models + +```go +handler.RegisterModel(schema, entity string, model interface{}) error +``` + +- `schema` — database schema name (e.g. `"public"`), or empty string for no schema prefix. +- `entity` — table/entity name (e.g. `"users"`). +- `model` — a pointer to a struct (e.g. `&User{}`). + +Each call immediately creates four MCP **tools** and one MCP **resource** for the model. + +--- + +## HTTP / SSE Transport + +The `*server.SSEServer` returned by any of the helpers below implements `http.Handler`, so it works with every Go HTTP framework. + +### Gorilla Mux + +```go +resolvemcp.SetupMuxRoutes(r, handler, "http://localhost:8080") +``` + +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) | + +### bunrouter + +```go +resolvemcp.SetupBunRouterRoutes(router, handler, "http://localhost:8080", "/mcp") +``` + +Registers `GET /mcp/sse` and `POST /mcp/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: + +```go +sse := handler.SSEServer("http://localhost:8080", "/mcp") + +// Gin +engine.Any("/mcp/*path", gin.WrapH(sse)) + +// net/http +http.Handle("/mcp/", http.StripPrefix("/mcp", sse)) + +// Echo +e.Any("/mcp/*", echo.WrapHandler(sse)) +``` + +### Authentication + +Add middleware before the MCP routes. The handler itself has no auth layer. + +--- + +## MCP Tools + +### Tool Naming + +``` +{operation}_{schema}_{entity} // e.g. read_public_users +{operation}_{entity} // e.g. read_users (when schema is empty) +``` + +Operations: `read`, `create`, `update`, `delete`. + +### Read Tool — `read_{schema}_{entity}` + +Fetch one or many records. + +| Argument | Type | Description | +|---|---|---| +| `id` | string | Primary key value. Omit to return multiple records. | +| `limit` | number | Max records per page (recommended: 10–100). | +| `offset` | number | Records to skip (offset-based pagination). | +| `cursor_forward` | string | PK of the **last** record on the current page (next-page cursor). | +| `cursor_backward` | string | PK of the **first** record on the current page (prev-page cursor). | +| `columns` | array | Column names to include. Omit for all columns. | +| `omit_columns` | array | Column names to exclude. | +| `filters` | array | Filter objects (see [Filtering](#filtering)). | +| `sort` | array | Sort objects (see [Sorting](#sorting)). | +| `preloads` | array | Relation preload objects (see [Preloading](#preloading)). | + +**Response:** +```json +{ + "success": true, + "data": [...], + "metadata": { + "total": 100, + "filtered": 100, + "count": 10, + "limit": 10, + "offset": 0 + } +} +``` + +### Create Tool — `create_{schema}_{entity}` + +Insert one or more records. + +| Argument | Type | Description | +|---|---|---| +| `data` | object \| array | Single object or array of objects to insert. | + +Array input runs inside a single transaction — all succeed or all fail. + +**Response:** +```json +{ "success": true, "data": { ... } } +``` + +### Update Tool — `update_{schema}_{entity}` + +Partially update an existing record. Only non-null, non-empty fields in `data` are applied; existing values are preserved for omitted fields. + +| Argument | Type | Description | +|---|---|---| +| `id` | string | Primary key of the record. Can also be included inside `data`. | +| `data` | object (required) | Fields to update. | + +**Response:** +```json +{ "success": true, "data": { ...merged record... } } +``` + +### Delete Tool — `delete_{schema}_{entity}` + +Delete a record by primary key. **Irreversible.** + +| Argument | Type | Description | +|---|---|---| +| `id` | string (required) | Primary key of the record to delete. | + +**Response:** +```json +{ "success": true, "data": { ...deleted record... } } +``` + +### Resource — `{schema}.{entity}` + +Each model is also registered as an MCP resource with URI `schema.entity` (or just `entity` when schema is empty). Reading the resource returns up to 100 records as `application/json`. + +--- + +## Filtering + +Pass an array of filter objects to the `filters` argument: + +```json +[ + { "column": "status", "operator": "=", "value": "active" }, + { "column": "age", "operator": ">", "value": 18, "logic_operator": "AND" }, + { "column": "role", "operator": "in", "value": ["admin", "editor"], "logic_operator": "OR" } +] +``` + +### Supported Operators + +| Operator | Aliases | Description | +|---|---|---| +| `=` | `eq` | Equal | +| `!=` | `neq`, `<>` | Not equal | +| `>` | `gt` | Greater than | +| `>=` | `gte` | Greater than or equal | +| `<` | `lt` | Less than | +| `<=` | `lte` | Less than or equal | +| `like` | | SQL LIKE (case-sensitive) | +| `ilike` | | SQL ILIKE (case-insensitive) | +| `in` | | Value in list | +| `is_null` | | Column IS NULL | +| `is_not_null` | | Column IS NOT NULL | + +### Logic Operators + +- `"logic_operator": "AND"` (default) — filter is AND-chained with the previous condition. +- `"logic_operator": "OR"` — filter is OR-grouped with the previous condition. + +Consecutive OR filters are grouped into a single `(cond1 OR cond2 OR ...)` clause. + +--- + +## Sorting + +```json +[ + { "column": "created_at", "direction": "desc" }, + { "column": "name", "direction": "asc" } +] +``` + +--- + +## Pagination + +### Offset-Based + +```json +{ "limit": 20, "offset": 40 } +``` + +### Cursor-Based + +Cursor pagination uses a SQL `EXISTS` subquery for stable, efficient paging. Always pair with a `sort` argument. + +```json +// Next page: pass the PK of the last record on the current page +{ "cursor_forward": "42", "limit": 20, "sort": [{"column": "id", "direction": "asc"}] } + +// Previous page: pass the PK of the first record on the current page +{ "cursor_backward": "23", "limit": 20, "sort": [{"column": "id", "direction": "asc"}] } +``` + +--- + +## Preloading Relations + +```json +[ + { "relation": "Profile" }, + { "relation": "Orders" } +] +``` + +Available relations are listed in each tool's description. Only relations defined on the model struct are valid. + +--- + +## Hook System + +Hooks let you intercept and modify CRUD operations at well-defined lifecycle points. + +### Hook Types + +| Constant | Fires | +|---|---| +| `BeforeHandle` | After model resolution, before operation dispatch (all CRUD) | +| `BeforeRead` / `AfterRead` | Around read queries | +| `BeforeCreate` / `AfterCreate` | Around insert | +| `BeforeUpdate` / `AfterUpdate` | Around update | +| `BeforeDelete` / `AfterDelete` | Around delete | + +### Registering Hooks + +```go +handler.Hooks().Register(resolvemcp.BeforeCreate, func(ctx *resolvemcp.HookContext) error { + // Inject a timestamp before insert + if data, ok := ctx.Data.(map[string]interface{}); ok { + data["created_at"] = time.Now() + } + return nil +}) + +// Register the same hook for multiple events +handler.Hooks().RegisterMultiple( + []resolvemcp.HookType{resolvemcp.BeforeCreate, resolvemcp.BeforeUpdate}, + auditHook, +) +``` + +### HookContext Fields + +| Field | Type | Description | +|---|---|---| +| `Context` | `context.Context` | Request context | +| `Handler` | `*Handler` | The resolvemcp handler | +| `Schema` | `string` | Database schema name | +| `Entity` | `string` | Entity/table name | +| `Model` | `interface{}` | Registered model instance | +| `Options` | `common.RequestOptions` | Parsed request options (read operations) | +| `Operation` | `string` | `"read"`, `"create"`, `"update"`, or `"delete"` | +| `ID` | `string` | Primary key from request (read/update/delete) | +| `Data` | `interface{}` | Input data (create/update — modifiable) | +| `Result` | `interface{}` | Output data (set by After hooks) | +| `Error` | `error` | Operation error, if any | +| `Query` | `common.SelectQuery` | Live query object (available in `BeforeRead`) | +| `Tx` | `common.Database` | Database/transaction handle | +| `Abort` | `bool` | Set to `true` to abort the operation | +| `AbortMessage` | `string` | Error message returned when aborting | +| `AbortCode` | `int` | Optional status code for the abort | + +### Aborting an Operation + +```go +handler.Hooks().Register(resolvemcp.BeforeDelete, func(ctx *resolvemcp.HookContext) error { + ctx.Abort = true + ctx.AbortMessage = "deletion is disabled" + return nil +}) +``` + +### Managing Hooks + +```go +registry := handler.Hooks() +registry.HasHooks(resolvemcp.BeforeCreate) // bool +registry.Clear(resolvemcp.BeforeCreate) // remove hooks for one type +registry.ClearAll() // remove all hooks +``` + +--- + +## Context Helpers + +Request metadata is threaded through `context.Context` during handler execution. Hooks and custom tools can read it: + +```go +schema := resolvemcp.GetSchema(ctx) +entity := resolvemcp.GetEntity(ctx) +tableName := resolvemcp.GetTableName(ctx) +model := resolvemcp.GetModel(ctx) +modelPtr := resolvemcp.GetModelPtr(ctx) +``` + +You can also set values manually (e.g. in middleware): + +```go +ctx = resolvemcp.WithSchema(ctx, "tenant_a") +``` + +--- + +## Adding Custom MCP Tools + +Access the underlying `*server.MCPServer` to register additional tools: + +```go +mcpServer := handler.MCPServer() +mcpServer.AddTool(myTool, myHandler) +``` + +--- + +## Table Name Resolution + +The handler resolves table names in priority order: + +1. `TableNameProvider` interface — `TableName() string` (can return `"schema.table"`) +2. `SchemaProvider` interface — `SchemaName() string` (combined with entity name) +3. Fallback: `schema.entity` (or `schema_entity` for SQLite) diff --git a/pkg/resolvemcp/handler.go b/pkg/resolvemcp/handler.go index 42bc812..d7a57b8 100644 --- a/pkg/resolvemcp/handler.go +++ b/pkg/resolvemcp/handler.go @@ -52,6 +52,19 @@ 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 { + return server.NewSSEServer( + h.mcpServer, + server.WithBaseURL(baseURL), + server.WithBasePath(basePath), + ) +} + // 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 f85531e..51bafc1 100644 --- a/pkg/resolvemcp/resolvemcp.go +++ b/pkg/resolvemcp/resolvemcp.go @@ -21,6 +21,7 @@ import ( "github.com/gorilla/mux" "github.com/mark3labs/mcp-go/server" "github.com/uptrace/bun" + bunrouter "github.com/uptrace/bunrouter" "gorm.io/gorm" "github.com/bitechdev/ResolveSpec/pkg/common" @@ -81,3 +82,19 @@ func NewSSEServer(handler *Handler, baseURL, basePath string) *server.SSEServer server.WithBasePath(basePath), ) } + +// SetupBunRouterRoutes mounts the MCP HTTP/SSE endpoints on a bunrouter router. +// +// Two routes are registered under the given basePath prefix: +// - 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), + ) + + router.GET(basePath+"/sse", bunrouter.HTTPHandler(sseServer.SSEHandler())) + router.POST(basePath+"/message", bunrouter.HTTPHandler(sseServer.MessageHandler())) +}