mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-04-07 00:16:21 +00:00
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.
This commit is contained in:
386
pkg/resolvemcp/README.md
Normal file
386
pkg/resolvemcp/README.md
Normal file
@@ -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)
|
||||||
@@ -52,6 +52,19 @@ func (h *Handler) MCPServer() *server.MCPServer {
|
|||||||
return h.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.
|
// RegisterModel registers a model and immediately exposes it as MCP tools and a resource.
|
||||||
func (h *Handler) RegisterModel(schema, entity string, model interface{}) error {
|
func (h *Handler) RegisterModel(schema, entity string, model interface{}) error {
|
||||||
fullName := buildModelName(schema, entity)
|
fullName := buildModelName(schema, entity)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
|
bunrouter "github.com/uptrace/bunrouter"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
@@ -81,3 +82,19 @@ func NewSSEServer(handler *Handler, baseURL, basePath string) *server.SSEServer
|
|||||||
server.WithBasePath(basePath),
|
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()))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user