From c1153522f2ed6a1ce74dbac416141f3b1906cedf Mon Sep 17 00:00:00 2001 From: Hein Date: Thu, 1 Oct 2026 13:42:14 +0200 Subject: [PATCH] docs(resolvemcp): rewrite README for meta tools, guard and limits; update plan status --- README.md | 26 ++-- audit/mcp_plan.md | 4 +- audit/pkg/resolvemcp.audit.md | 1 + pkg/resolvemcp/README.md | 240 ++++++++++++++++------------------ 4 files changed, 124 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 56daeb0..a4bfe6e 100644 --- a/README.md +++ b/README.md @@ -277,32 +277,28 @@ ResolveMCP exposes registered models as Model Context Protocol tools so AI model ```go import "github.com/bitechdev/ResolveSpec/pkg/resolvemcp" -// Create handler -handler := resolvemcp.NewHandlerWithGORM(db) +handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{BaseURL: "http://localhost:8080", BasePath: "/mcp"}) + +securityList, _ := security.NewSecurityList(provider) +resolvemcp.RegisterSecurityHooks(handler, securityList) -// Register models — must be done BEFORE Build() handler.RegisterModel("public", "users", &User{}) handler.RegisterModel("public", "posts", &Post{}) -// Finalize: registers MCP tools and resources -handler.Build() - -// Mount SSE transport on your existing router +// Mount the guarded SSE transport (OAuth bearer, session token or API key required) router := mux.NewRouter() -resolvemcp.SetupMuxRoutes(router, handler, "http://localhost:8080") +resolvemcp.SetupMuxRoutes(router, handler, securityList) // MCP clients connect to: // SSE stream: GET http://localhost:8080/mcp/sse // Messages: POST http://localhost:8080/mcp/message // -// Auto-registered tools per model: -// read_public_users — filter, sort, paginate, preload -// create_public_users — insert a new record -// update_public_users — update a record by ID -// delete_public_users — delete a record by ID +// Fixed meta tools (independent of the number of models): +// list_tables, describe_table, select_table, insert_into_table, +// update_table, delete_from_table, list_functions, call_function ``` -For complete documentation, see [pkg/resolvemcp/README.md](pkg/resolvemcp/README.md) (if present) or the package source. +For complete documentation, see [pkg/resolvemcp/README.md](pkg/resolvemcp/README.md) . ## Architecture @@ -648,7 +644,7 @@ For documentation, see [pkg/cache/README.md](pkg/cache/README.md). Authentication and authorization framework with hooks integration. Database-backed providers use PostgreSQL stored procedures by default, with a direct SQL backend (SQLite, MySQL, SQL Server, or Postgres without the procedures) selected through `lookup.Config`. -For documentation, see [pkg/security/README.md](pkg/security/README.md) (see "Direct Mode" for the SQLite/portable-SQL path). +For documentation, see [pkg/security/README.md](pkg/security/README.md) (see "Database access (lookup)" for the SQLite/portable-SQL path). #### Middleware diff --git a/audit/mcp_plan.md b/audit/mcp_plan.md index 61d6676..b2b96e0 100644 --- a/audit/mcp_plan.md +++ b/audit/mcp_plan.md @@ -1,6 +1,6 @@ # resolvemcp rewrite plan -Source: `audit/pkg/resolvemcp.audit.md`. Status: plan only, no code changed. +Source: `audit/pkg/resolvemcp.audit.md`. Status: items 1-8 and 10 implemented; item 9 (tests) mostly done, see git log. ## Goal @@ -72,7 +72,7 @@ Same rules as resolvespec CRUD, plus guardrails. ### 1. API key login (`pkg/security`) - Existing: keystore has `ValidateKey` and `KeyStoreAuthenticator`; `Login` needs a password; no key-to-session path. -- Add `resolvespec_login_api_key` to `SQLNames` (default + override) and a SQL script beside the existing procedures. Contract: `p_success, p_error, p_data`, input raw key; hashes, validates active/non-expired key, creates session for the key's user. +- Add `resolvespec_login_api_key` to `lookup.ProcNames` (default + override; was `SQLNames` before the lookup refactor) and a SQL script beside the existing procedures. Contract: `p_success, p_error, p_data`, input raw key; hashes, validates active/non-expired key, creates session for the key's user. - Add `DatabaseAuthenticator.LoginWithAPIKey(ctx, rawKey)`; procedure first, direct-SQL fallback via `ShouldUseProcedure`. - Hashed lookup; same generic error for unknown, expired or inactive key; no key material in logs. - Expose through the chain/composite authenticators so the middleware can accept it. diff --git a/audit/pkg/resolvemcp.audit.md b/audit/pkg/resolvemcp.audit.md index 6f1d8df..f49b170 100644 --- a/audit/pkg/resolvemcp.audit.md +++ b/audit/pkg/resolvemcp.audit.md @@ -6,6 +6,7 @@ | **Files** | `handler.go` (901), `tools.go` (720), `cursor.go`, `oauth2.go`, `oauth2_server.go`, `annotation.go`, `hooks.go`, `security_hooks.go`, `context.go`, `resolvemcp.go` | | **Tests** | `tools_test.go` (34), `tx_test.go` (207); `go test` passes. No hostile-input tests, no `-race` | | **Audit date** | 2026-09-30 | +| **Status** | Rewrite implemented (meta tools, guard, limits, guardrails, function registry); see `audit/mcp_plan.md` | | **Axes** | thread locking/waiting, slowness, security, panic handling & logging, agent usability | | **Threat model** | hostile or confused MCP client (LLM agent, possibly prompt-injected); tool arguments are attacker-controlled | | **Depth** | targeted (request path, security wiring; verified against source) | diff --git a/pkg/resolvemcp/README.md b/pkg/resolvemcp/README.md index 206fdf8..545c62b 100644 --- a/pkg/resolvemcp/README.md +++ b/pkg/resolvemcp/README.md @@ -1,46 +1,53 @@ # 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. +Package `resolvemcp` exposes registered database models to AI clients through a **fixed set of Model Context Protocol (MCP) meta tools** over SSE or Streamable HTTP. The tool count does not grow with the number of models. It mirrors the `resolvespec` package — same model registration, filter/sort/pagination/preload options, hook system and security rules. + +Every endpoint **requires authentication**; tools run as the authenticated caller. ## Quick Start ```go import ( "github.com/bitechdev/ResolveSpec/pkg/resolvemcp" + "github.com/bitechdev/ResolveSpec/pkg/security" "github.com/gorilla/mux" ) -// 1. Create a handler handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{ - BaseURL: "http://localhost:8080", + BaseURL: "http://localhost:8080", + BasePath: "/mcp", }) -// 2. Register models +securityList, _ := security.NewSecurityList(provider) +resolvemcp.RegisterSecurityHooks(handler, securityList) + handler.RegisterModel("public", "users", &User{}) handler.RegisterModel("public", "orders", &Order{}) -// 3. Mount routes r := mux.NewRouter() -resolvemcp.SetupMuxRoutes(r, handler) +resolvemcp.SetupMuxRoutes(r, handler, securityList) // guarded ``` --- ## 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 +| Field | Default | Purpose | +|---|---|---| +| `BaseURL` | request-detected | Public base URL sent to SSE clients | +| `BasePath` | request-detected | Mount path (e.g. `/mcp`) | +| `DefaultLimit` | 50 | Page size when a read gives no limit | +| `MaxLimit` | 1000 | Larger limits are clamped | +| `MaxOffset` | 100000 | Larger offsets are rejected | +| `MaxBatch` | 100 | Rows in one batch insert | +| `MaxPreloadDepth` | 2 | Depth of a preload path (`a.b.c`) | +| `MaxWriteRows` | 100 | Rows a filter-based update/delete may touch | +| `QueryTimeout` | 30s | One tool call, hooks and queries included | +| `ConfirmTTL` | 5m | Lifetime of a filter-write confirm token | +| `AllowedHosts` | any | Host allowlist for SSE when `BaseURL` is empty (prefer setting `BaseURL`) | +| `EnableAnnotations` | false | Registers `resolvespec_annotate` (opt-in) | - // BasePath is the URL path prefix where MCP endpoints are mounted (e.g. "/mcp"). - // Required. - BasePath string -} -``` +--- ## Handler Creation @@ -63,14 +70,36 @@ handler.RegisterModel(schema, entity string, model interface{}) error - `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. +`RegisterModel` only adds the model to the registry; it creates no tools. All registered models are visible to `list_tables`; per-entity rules (see [Security](#security)) restrict the operations. + +### Functions + +```go +// Go callback +handler.RegisterFunction(resolvemcp.Function{ + Name: "recalc_totals", + Description: "Recalculate order totals", + Params: []resolvemcp.FunctionParam{{Name: "order_id", Type: resolvemcp.ParamNumber, Required: true}}, + Handler: func(ctx context.Context, tx common.Database, args map[string]any) (any, error) { return nil, nil }, + Authorize: func(ctx context.Context) error { return nil }, // optional per-caller gate +}) + +// SQL procedure: SELECT * FROM public.my_proc($1, $2::jsonb) +handler.RegisterFunction(resolvemcp.Function{ + Name: "my_proc", Procedure: "public.my_proc", + Params: []resolvemcp.FunctionParam{{Name: "a", Type: resolvemcp.ParamString, Required: true}}, +}) +``` + +Only registered functions are callable. Arguments are validated against `Params`; calls run in a transaction (`OnTxBegin` fired). A function the caller is not authorized for looks identical to an unknown one. --- ## HTTP Transports -`Config.BasePath` is required and used for all route registration. -`Config.BaseURL` is optional — when empty it is detected from each request. +`Config.BasePath` is used for route registration. `Config.BaseURL` is optional — when empty it is detected from each request. + +All `Setup*`/`New*` helpers wrap the endpoint in `Guard(securityList)`: a valid OAuth bearer token, session token or API key is required, there is no guest/optional mode, and it fails closed. `handler.SSEServer()` / `handler.StreamableHTTPServer()` and the `*Unauthenticated` variants serve **without** a guard and log a warning; use them only behind your own authentication. Two transports are supported: **SSE** (legacy, two-endpoint) and **Streamable HTTP** (recommended, single-endpoint). @@ -83,7 +112,7 @@ Two endpoints: `GET {BasePath}/sse` (subscribe) + `POST {BasePath}/message` (sen #### Gorilla Mux ```go -resolvemcp.SetupMuxRoutes(r, handler) +resolvemcp.SetupMuxRoutes(r, handler, securityList) ``` | Route | Method | Description | @@ -94,13 +123,13 @@ resolvemcp.SetupMuxRoutes(r, handler) #### bunrouter ```go -resolvemcp.SetupBunRouterRoutes(router, handler) +resolvemcp.SetupBunRouterRoutes(router, handler, securityList) ``` #### Gin / net/http / Echo ```go -sse := handler.SSEServer() +sse := resolvemcp.NewSSEServer(handler, securityList) // guarded engine.Any("/mcp/*path", gin.WrapH(sse)) // Gin http.Handle("/mcp/", sse) // net/http @@ -116,7 +145,7 @@ Single endpoint at `{BasePath}`. Handles POST (client→server) and GET (server #### Gorilla Mux ```go -resolvemcp.SetupMuxStreamableHTTPRoutes(r, handler) +resolvemcp.SetupMuxStreamableHTTPRoutes(r, handler, securityList) ``` Mounts the handler at `{BasePath}` (all methods). @@ -124,7 +153,7 @@ Mounts the handler at `{BasePath}` (all methods). #### bunrouter ```go -resolvemcp.SetupBunRouterStreamableHTTPRoutes(router, handler) +resolvemcp.SetupBunRouterStreamableHTTPRoutes(router, handler, securityList) ``` Registers GET, POST, DELETE on `{BasePath}`. @@ -132,8 +161,7 @@ Registers GET, POST, DELETE on `{BasePath}`. #### Gin / net/http / Echo ```go -h := handler.StreamableHTTPServer() -// or: h := resolvemcp.NewStreamableHTTPHandler(handler) +h := resolvemcp.NewStreamableHTTPHandler(handler, securityList) // guarded engine.Any("/mcp", gin.WrapH(h)) // Gin http.Handle("/mcp", h) // net/http @@ -306,6 +334,8 @@ Call `RegisterSecurityHooks` **once**, after creating the handler and before reg | `BeforeUpdate` | Blocks update if `CanUpdate` is false; drops hidden/masked columns from the payload | | `BeforeDelete` | Blocks delete if `CanDelete` is false | +Additional hooks: `BeforeScan` (row pre-read of update/delete/filter writes), `BeforeCall`/`AfterCall` (functions) and `OnTxBegin`. Hooks are mutex-protected and panics in hooks are recovered. + ### Per-entity operation rules Use `RegisterModelWithRules` instead of `RegisterModel` to set access rules at registration time: @@ -362,121 +392,58 @@ handler.SetModelRules("public", "users", modelregistry.ModelRules{ ## MCP Tools -### Tool Naming +Fixed set, independent of the models. `table` is `schema.entity`. Errors return `{"success":false,"error":{"code","message"}}` with codes `invalid_argument`, `not_found`, `forbidden`, `limit_exceeded`, `internal` (internal details are logged, the client gets a reference id). -``` -{operation}_{schema}_{entity} // e.g. read_public_users -{operation}_{entity} // e.g. read_users (when schema is empty) -``` +| Tool | Purpose | +|---|---| +| `list_tables` | Tables the caller may use and the allowed operations | +| `describe_table` | Columns, PK, relations, writable columns, operations, limits | +| `select_table` | Read rows (filters, sort, columns, preloads, paging) | +| `insert_into_table` | Insert one row or a capped batch | +| `update_table` | Update by `id` or `filters` | +| `delete_from_table` | Delete by `id` or `filters` | +| `list_functions` | Registered functions the caller may call, with parameters | +| `call_function` | Call a registered function | +| `resolvespec_annotate` | Only with `EnableAnnotations` | -Operations: `read`, `create`, `update`, `delete`. - -### Read Tool — `read_{schema}_{entity}` - -Fetch one or many records. +### `select_table` | 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)). | +| `table` | string (required) | `schema.entity` | +| `id` | string | Primary key of one row | +| `filters`, `sort` | array | See [Filtering](#filtering), [Sorting](#sorting) | +| `columns`, `omit_columns` | array | Column selection | +| `preloads` | array | Relations (validated against the model, max depth `MaxPreloadDepth`) | +| `limit`, `offset` | number | Clamped to `MaxLimit` / rejected above `MaxOffset` | +| `cursor_forward`, `cursor_backward` | string | PK cursor, requires `sort` | +| `include_count` | boolean | Also compute totals (slower); otherwise `total`/`filtered` are 0 | -**Response:** -```json -{ - "success": true, - "data": [...], - "metadata": { - "total": 100, - "filtered": 100, - "count": 10, - "limit": 10, - "offset": 0 - } -} -``` +Response: `{"success":true,"data":[...],"metadata":{"total","filtered","count","limit","offset"}}` -### Create Tool — `create_{schema}_{entity}` +### `insert_into_table` -Insert one or more records. +`data` is an object or an array (one transaction, max `MaxBatch`). Unknown, duplicate or read-only keys are rejected; keys are resolved to columns from the model. -| Argument | Type | Description | -|---|---|---| -| `data` | object \| array | Single object or array of objects to insert. | +### `update_table` / `delete_from_table` -Array input runs inside a single transaction — all succeed or all fail. +Either `id` or `filters` is required. -**Response:** -```json -{ "success": true, "data": { ... } } -``` +| Mode | Behaviour | +|---|---| +| `id` | One row, applied immediately. The row is locked and row security applies; an invisible row is "not found". | +| `filters` | Matching rows are found inside the transaction (row security applied, max `MaxWriteRows`). The first call returns a preview and a `confirm_token`; repeat the identical call with `confirm_token` to apply. | +| `dry_run` | Report match count and preview ids; change nothing. | -### Update Tool — `update_{schema}_{entity}` +The token is single-use, expires after `ConfirmTTL`, and is bound to user, table, operation and a hash of filters, data and matched ids; it is held in memory (lost on restart, single instance). Update changes only the keys in `data`; `null` sets NULL. Filters are strictly parsed (never silently dropped), columns validated, and only the documented operators are accepted. -Partially update an existing record. Only non-null, non-empty fields in `data` are applied; existing values are preserved for omitted fields. +### `call_function` -| Argument | Type | Description | -|---|---|---| -| `id` | string | Primary key of the record. Can also be included inside `data`. | -| `data` | object (required) | Fields to update. | +`name` and `arguments` (object). See [Functions](#functions). -**Response:** -```json -{ "success": true, "data": { ...merged record... } } -``` +### `resolvespec_annotate` -### 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... } } -``` - -### Annotation Tool — `resolvespec_annotate` - -Store or retrieve freeform annotation records for any tool, model, or entity. Registered automatically on every handler. - -| Argument | Type | Description | -|---|---|---| -| `tool_name` | string (required) | Key to annotate — an MCP tool name (e.g. `read_public_users`), a model name (e.g. `public.users`), or any other identifier. | -| `annotations` | object | Annotation data to persist. Omit to retrieve existing annotations instead. | - -**Set annotations** (calls `resolvespec_set_annotation(tool_name, annotations)`): -```json -{ "tool_name": "read_public_users", "annotations": { "description": "Returns active users", "owner": "platform-team" } } -``` -**Response:** -```json -{ "success": true, "tool_name": "read_public_users", "action": "set" } -``` - -**Get annotations** (calls `resolvespec_get_annotation(tool_name)`): -```json -{ "tool_name": "read_public_users" } -``` -**Response:** -```json -{ "success": true, "tool_name": "read_public_users", "action": "get", "annotations": { ... } } -``` - ---- - -### 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`. +Opt-in (`Config.EnableAnnotations`). Stores/retrieves freeform annotations through `resolvespec_set_annotation` / `resolvespec_get_annotation`; runs `BeforeHandle` hooks (`annotate_set` / `annotate_get`) and a transaction. --- @@ -576,6 +543,9 @@ Hooks let you intercept and modify CRUD operations at well-defined lifecycle poi | `BeforeCreate` / `AfterCreate` | Around insert | | `BeforeUpdate` / `AfterUpdate` | Around update | | `BeforeDelete` / `AfterDelete` | Around delete | +| `BeforeScan` | Row pre-read for update/delete/filter writes | +| `BeforeCall` / `AfterCall` | Around `call_function` | +| `OnTxBegin` | Start of every transaction | ### Registering Hooks @@ -605,7 +575,7 @@ handler.Hooks().RegisterMultiple( | `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"` | +| `Operation` | `string` | `"read"`, `"create"`, `"update"`, `"delete"`, `"call"`, `"annotate_set"` or `"annotate_get"` | | `ID` | `string` | Primary key from request (read/update/delete) | | `Data` | `interface{}` | Input data (create/update — modifiable) | | `Result` | `interface{}` | Output data (set by After hooks) | @@ -639,7 +609,7 @@ registry.ClearAll() // remove all hooks ## Context Helpers -Request metadata is threaded through `context.Context` during handler execution. Hooks and custom tools can read it: +The caller's `security.UserContext` reaches every tool call through the request context. Request metadata is threaded through `context.Context` during handler execution. Hooks and custom tools can read it: ```go schema := resolvemcp.GetSchema(ctx) @@ -659,7 +629,7 @@ ctx = resolvemcp.WithSchema(ctx, "tenant_a") ## Adding Custom MCP Tools -Access the underlying `*server.MCPServer` to register additional tools: +Access the underlying `*server.MCPServer` to register additional tools (they sit behind the same guard). Prefer `RegisterFunction` for database-backed actions: ```go mcpServer := handler.MCPServer() @@ -675,3 +645,13 @@ 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) + +--- + +## Breaking changes + +- Per-model tools (`read_/create_/update_/delete_{schema}_{entity}`) and per-model resources are gone; use the meta tools. +- `Setup*` / `NewSSEServer` / `NewStreamableHTTPHandler` take a `*security.SecurityList` and require authentication. `OptionalAuth*` helpers were removed; `*Unauthenticated` variants exist for explicit opt-out. +- `resolvespec_annotate` is opt-in via `Config.EnableAnnotations`. +- `Handler.Build()` is not needed. +- Update is now a partial update by validated keys; reads are capped by the configured limits.