From 5a74defaecd0ce13d3ae4599d906800509ed7778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Thu, 3 Sep 2026 18:43:05 +0200 Subject: [PATCH 1/6] feat(mcp): add @supabase/server/mcp --- README.md | 29 + docs/api-reference.md | 125 +- docs/error-handling.md | 36 +- docs/mcp.md | 163 +++ .../migrations/20260102000000_mcp_probe.sql | 109 ++ jsr.json | 12 +- package.json | 17 +- pnpm-lock.yaml | 80 +- scripts/check-declared-deps.mjs | 9 +- scripts/smoke-pack.mjs | 1 + skills/supabase-server/SKILL.md | 12 +- src/errors.test.ts | 59 + src/errors.ts | 151 ++- src/index.ts | 5 + src/mcp/__fixtures__/probe-spec.ts | 1170 +++++++++++++++++ src/mcp/generate.test.ts | 666 ++++++++++ src/mcp/generate.ts | 597 +++++++++ src/mcp/index.ts | 42 + src/mcp/register.test.ts | 123 ++ src/mcp/register.ts | 44 + src/mcp/types.ts | 57 + tsdown.config.ts | 2 + typedoc.json | 6 +- 23 files changed, 3448 insertions(+), 67 deletions(-) create mode 100644 docs/mcp.md create mode 100644 e2e/supabase/migrations/20260102000000_mcp_probe.sql create mode 100644 src/mcp/__fixtures__/probe-spec.ts create mode 100644 src/mcp/generate.test.ts create mode 100644 src/mcp/generate.ts create mode 100644 src/mcp/index.ts create mode 100644 src/mcp/register.test.ts create mode 100644 src/mcp/register.ts create mode 100644 src/mcp/types.ts diff --git a/README.md b/README.md index 3c5fdff..da31437 100644 --- a/README.md +++ b/README.md @@ -487,6 +487,33 @@ Needs `pg` installed (optional peer dependency) and a raw TCP socket: Node, Deno See [`docs/postgres.md`](docs/postgres.md) for standalone composition with `withClaims`, the grants requirement, and current limits. +## MCP tools from your schema + +> **Alpha.** `@supabase/server/mcp` tracks `@modelcontextprotocol/server` 2.x; generated tool names, schemas and annotations may change in a minor release. + +`generateTools` reads the OpenAPI description PostgREST publishes for the caller and builds one MCP tool per operation — `list_`, `get_`, `create_`, `update_`, `delete_` for every table and view, one tool per database function — with descriptions from `COMMENT ON`. `registerTools` hands them to the official MCP SDK. Tools run through `ctx.supabase`, so RLS applies. + +```ts +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' +import { withOAuthProtectedResource, withSupabase } from '@supabase/server' +import { generateTools, registerTools } from '@supabase/server/mcp' + +Deno.serve( + withOAuthProtectedResource( + withSupabase({ auth: 'user' }, async (req, { supabase }) => { + const handler = createMcpHandler(async () => { + const server = new McpServer({ name: 'notes-mcp', version: '0.1.0' }) + registerTools(server, await generateTools(supabase)) + return server + }) + return handler.fetch(req) + }), + ), +) +``` + +Requires `@modelcontextprotocol/server` (optional peer) and `@supabase/supabase-js` 2.115.0+. See [`docs/mcp.md`](docs/mcp.md) for what is generated, annotations, filtering, and limitations. + ## Environment Variables Automatically available in Supabase Edge Functions: @@ -546,6 +573,7 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like | `@supabase/server/middleware/postgres` | **Alpha.** `withPostgresClient` (RLS-scoped `ctx.postgres` client) | | `@supabase/server/middleware/postgres-admin` | **Alpha.** `withPostgresAdminClient` (`ctx.postgresAdmin`, bypasses RLS) | | `@supabase/server/oauth-protected-resource` | **Alpha.** `withOAuthProtectedResource`, `fromSupabaseUrl`, `resourceMetadataResponse`, `unauthorizedResponse` | +| `@supabase/server/mcp` | **Alpha.** `generateTools`, `registerTools` (MCP tools generated from the PostgREST schema) | | `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) | ## Documentation @@ -564,6 +592,7 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like | How do I handle errors? What codes exist? | [`docs/error-handling.md`](docs/error-handling.md) | | How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) | | How do I run raw SQL scoped to the caller by RLS? | [`docs/postgres.md`](docs/postgres.md) | +| How do I generate MCP tools from my schema? | [`docs/mcp.md`](docs/mcp.md) | | How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) | | What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) | | Does this library support legacy API keys or HS256 JWTs? | [`docs/auth-modes.md`](docs/auth-modes.md#legacy-keys-and-jwts-are-not-supported) | diff --git a/docs/api-reference.md b/docs/api-reference.md index ef18cca..ba0adb4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -443,6 +443,64 @@ Defaults to the `SUPABASE_DB_URL` environment variable. --- +## @supabase/server/mcp + +> **Alpha.** `@supabase/server/mcp` tracks `@modelcontextprotocol/server` 2.x. +> Generated tool names, input schemas and annotations may change in a minor +> release. Everything else in `@supabase/server` is stable. + +Requires `@modelcontextprotocol/server` `^2.0.0` (optional peer dependency) and `@supabase/supabase-js` 2.115.0 or newer. See [`mcp.md`](mcp.md). + +### generateTools + +```ts +function generateTools( + supabase: SupabaseClient, +): Promise> +``` + +Fetches the OpenAPI description PostgREST publishes for the client's schema through `supabase.getOpenApiSpec()` — carrying the caller's token, so it describes what the caller's role can reach — and derives one tool per operation: `list_`, `get_`, `create_`, `update_` and `delete_` per table or view, and one tool per database function at `/rpc/`. Descriptions come from `COMMENT ON`. Tools run through the same client, so Row Level Security applies. + +Returns a record keyed by tool name. `tool.name` is authoritative; the key is an index. + +Throws `ToolGenerationError` with code `SPEC_FETCH_FAILED` when the description cannot be read, or `TOOL_NAME_COLLISION` when two operations produce the same name. + +### registerTools + +```ts +function registerTools( + server: Pick, + tools: Record, +): void +``` + +Calls `server.registerTool(name, config, handler)` for every tool. Each `inputSchema` is wrapped with the SDK's `fromJsonSchema()`, so the SDK validates arguments and advertises the schema in `tools/list`. `_meta` is not forwarded. A name the server already has surfaces as the SDK's own error. + +### GeneratedTool + +```ts +interface GeneratedTool { + name: string + description: string + inputSchema: Record // JSON Schema + annotations: ToolAnnotations // from @modelcontextprotocol/server + _meta: ToolMeta + handler: (args: Record) => Promise +} +``` + +### ToolMeta + +```ts +interface ToolMeta { + kind: 'relation' | 'function' + name: string // the table, view, or function + method: 'GET' | 'POST' | 'PATCH' | 'DELETE' +} +``` + +--- + ## Types ### AuthMode @@ -691,6 +749,21 @@ class AuthError extends SupabaseServerError { } ``` +### ToolGenerationError + +```ts +class ToolGenerationError extends SupabaseServerError { + readonly status: 500 + constructor( + message: string, + code?: string, + options?: SupabaseServerErrorOptions, + ) +} +``` + +Thrown by `generateTools()` (`@supabase/server/mcp`). + ### ErrorPayload The JSON body every auto-responding layer returns, and the return type of `toJSON()`. @@ -721,28 +794,31 @@ interface SupabaseServerErrorOptions { ## Error Code Constants -| Constant | Value | Class | Meaning | -| ----------------------------------- | ----------------------------------- | ----------- | -------------------------------------------------------------------- | -| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error | -| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set | -| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found | -| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key | -| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found | -| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key | -| `MissingResourceServerError` | `'MISSING_RESOURCE_SERVER'` | `EnvError` | `withOAuthProtectedResource` cannot derive a `resourceServer` | -| `MissingAuthorizationServerError` | `'MISSING_AUTHORIZATION_SERVER'` | `EnvError` | `withOAuthProtectedResource` cannot derive an authorization server | -| `MissingConnectionStringError` | `'MISSING_CONNECTION_STRING'` | `EnvError` | No Postgres connection string configured | -| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error (401) | -| `MissingCredentialsError` | `'MISSING_CREDENTIALS'` | `AuthError` | Request carried no credentials at all (401) | -| `UnusableCredentialError` | `'UNUSABLE_CREDENTIAL'` | `AuthError` | A credential arrived but cannot be used (401) | -| `InvalidApiKeyError` | `'INVALID_API_KEY'` | `AuthError` | `apikey` matched no configured key (401) | -| `InvalidJwtError` | `'INVALID_JWT'` | `AuthError` | JWT failed verification (401) | -| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | Fallback credential failure (401) | -| `JwksNotConfiguredError` | `'JWKS_NOT_CONFIGURED'` | `AuthError` | JWT sent but no JWKS configured (500) | -| `JwksFetchFailedError` | `'JWKS_FETCH_FAILED'` | `AuthError` | Remote JWKS unreachable or unusable (500) | -| `NoKeysConfiguredError` | `'NO_KEYS_CONFIGURED'` | `AuthError` | Auth mode no configured key can match (500) | -| `UnsupportedRoleError` | `'UNSUPPORTED_ROLE'` | `AuthError` | `withPostgresClient` will not assume the caller's `role` claim (500) | -| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth (500) | +| Constant | Value | Class | Meaning | +| ----------------------------------- | ----------------------------------- | --------------------- | -------------------------------------------------------------------- | +| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error | +| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set | +| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found | +| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key | +| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found | +| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key | +| `MissingResourceServerError` | `'MISSING_RESOURCE_SERVER'` | `EnvError` | `withOAuthProtectedResource` cannot derive a `resourceServer` | +| `MissingAuthorizationServerError` | `'MISSING_AUTHORIZATION_SERVER'` | `EnvError` | `withOAuthProtectedResource` cannot derive an authorization server | +| `MissingConnectionStringError` | `'MISSING_CONNECTION_STRING'` | `EnvError` | No Postgres connection string configured | +| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error (401) | +| `MissingCredentialsError` | `'MISSING_CREDENTIALS'` | `AuthError` | Request carried no credentials at all (401) | +| `UnusableCredentialError` | `'UNUSABLE_CREDENTIAL'` | `AuthError` | A credential arrived but cannot be used (401) | +| `InvalidApiKeyError` | `'INVALID_API_KEY'` | `AuthError` | `apikey` matched no configured key (401) | +| `InvalidJwtError` | `'INVALID_JWT'` | `AuthError` | JWT failed verification (401) | +| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | Fallback credential failure (401) | +| `JwksNotConfiguredError` | `'JWKS_NOT_CONFIGURED'` | `AuthError` | JWT sent but no JWKS configured (500) | +| `JwksFetchFailedError` | `'JWKS_FETCH_FAILED'` | `AuthError` | Remote JWKS unreachable or unusable (500) | +| `NoKeysConfiguredError` | `'NO_KEYS_CONFIGURED'` | `AuthError` | Auth mode no configured key can match (500) | +| `UnsupportedRoleError` | `'UNSUPPORTED_ROLE'` | `AuthError` | `withPostgresClient` will not assume the caller's `role` claim (500) | +| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth (500) | +| `ToolGenerationGenericError` | `'TOOL_GENERATION_ERROR'` | `ToolGenerationError` | Generic tool generation error (500) | +| `SpecFetchFailedError` | `'SPEC_FETCH_FAILED'` | `ToolGenerationError` | PostgREST OpenAPI description could not be read (500) | +| `ToolNameCollisionError` | `'TOOL_NAME_COLLISION'` | `ToolGenerationError` | Two operations produce the same tool name (500) | Also exported: `ErrorSource` (`'@supabase/server'`) and `ErrorCodeHeader` (`'x-supabase-server-error'`). @@ -781,6 +857,11 @@ const Errors: { supportedRoles }) => AuthError [CreateSupabaseClientError]: (options?: { cause?: unknown }) => AuthError + [SpecFetchFailedError]: (failure: SpecFetchFailure) => ToolGenerationError + [ToolNameCollisionError]: (context: { + name: string + operations: readonly string[] + }) => ToolGenerationError } ``` diff --git a/docs/error-handling.md b/docs/error-handling.md index f5523f3..08ec96e 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -70,7 +70,8 @@ The status code and the `x-supabase-server-error` header are unaffected, and `me Error └── SupabaseServerError ← catch this for anything from @supabase/server ├── EnvError ← always status 500 - └── AuthError ← status 401 or 500 + ├── AuthError ← status 401 or 500 + └── ToolGenerationError ← always status 500 ``` ```ts @@ -261,6 +262,38 @@ Set `SUPABASE_DB_URL`, or pass `connectionString` to the middleware — `details Generic environment error. The default code when constructing an `EnvError` yourself. +## ToolGenerationError codes + +Thrown by `generateTools()` from `@supabase/server/mcp` when MCP tools cannot be generated from the PostgREST description. Always `status: 500` — neither cause is the caller's fault. + +| Code | Meaning | +| ------------------------------------------------- | --------------------------------------------------- | +| [`SPEC_FETCH_FAILED`](#spec_fetch_failed) | The PostgREST OpenAPI description could not be read | +| [`TOOL_NAME_COLLISION`](#tool_name_collision) | Two generated operations produce the same tool name | +| [`TOOL_GENERATION_ERROR`](#tool_generation_error) | Generic tool generation error | + +### `SPEC_FETCH_FAILED` + +`supabase.getOpenApiSpec()` did not return a Swagger 2.0 document. The `hint` branches on what happened: + +- **404 or 406** — PostgREST is not serving an OpenAPI description. OpenAPI output is disabled on the project's Data API (`openapi-mode`), or the client URL does not point at a PostgREST endpoint. +- **401 or 403** — PostgREST rejected the credentials. The Supabase client must carry a valid API key and, for caller-scoped generation, the caller's access token. +- **0** — the request never reached PostgREST. Check the project URL and network connectivity. +- **A body without `swagger` and `definitions`** — another service answered, or OpenAPI output is disabled. +- **No `getOpenApiSpec` method** — the `@supabase/supabase-js` client predates 2.115.0. Upgrade. + +`details.status` carries the HTTP status when there was a response; `cause` carries the `PostgrestError`. + +### `TOOL_NAME_COLLISION` + +Two operations would produce the same tool name — typically a database function named exactly like a generated relation tool, such as a function `list_notes` next to a table `notes`. Generation fails rather than silently replacing one. + +Tool names are one namespace across tables, views and functions. Rename the database function, or revoke the role's privilege on one of the two so it leaves the description. `details.name` is the colliding name and `details.operations` names both operations. + +### `TOOL_GENERATION_ERROR` + +Generic tool generation error. The default code when constructing a `ToolGenerationError` yourself. + ## How errors surface in each layer | Function | Pattern | What happens on error | @@ -276,6 +309,7 @@ Generic environment error. The default code when constructing an `EnvError` your | `createContextClient()` | **Throws** | Throws `EnvError` | | `createAdminClient()` | **Throws** | Throws `EnvError` | | `withOAuthProtectedResource()` | **Throws** | Throws `EnvError` when required off Edge Functions and unconfigured | +| `generateTools()` | **Throws** | Throws `ToolGenerationError` (`@supabase/server/mcp`) | | Hono `withSupabase()` | HTTPException | Throws `HTTPException` with `cause: AuthError` | `verifyAuth()` also has the raw request in hand, so it adds diagnostics `verifyCredentials()` can't see — most usefully, an `Authorization` header that was present but unusable. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..18a840c --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,163 @@ +# MCP tools from your schema (`@supabase/server/mcp`) + +> **Alpha.** `@supabase/server/mcp` tracks `@modelcontextprotocol/server` 2.x. +> Generated tool names, input schemas and annotations may change in a minor release. + +Every MCP tool on a Supabase project used to be written by hand. `generateTools` builds them from what the project already contains: it reads the OpenAPI description PostgREST publishes for the caller and turns every operation into a tool. Add a table, get its tools. Grant execute on a function, get a tool for it. No tool code in between. + +Think of it as the Data API exposed to MCP. The same client, the same role, the same Row Level Security. Whatever `ctx.supabase` can reach becomes a tool, unless your code removes or replaces it. + +```ts +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' +import { withOAuthProtectedResource, withSupabase } from '@supabase/server' +import { generateTools, registerTools } from '@supabase/server/mcp' + +Deno.serve( + withOAuthProtectedResource( + withSupabase({ auth: 'user' }, async (req, { supabase }) => { + const handler = createMcpHandler(async () => { + const server = new McpServer({ name: 'notes-mcp', version: '0.1.0' }) + registerTools(server, await generateTools(supabase)) + return server + }) + return handler.fetch(req) + }), + ), +) +``` + +## Requirements + +| Dependency | Version | Why | +| ------------------------------ | ---------- | -------------------------------------------------------------------------------------------- | +| `@modelcontextprotocol/server` | `^2.0.0` | Optional peer dependency. `registerTools` hands tools to its `McpServer`. | +| `@supabase/supabase-js` | `2.115.0`+ | `generateTools` reads the description through `supabase.getOpenApiSpec()`, added in 2.115.0. | + +On Supabase Edge Functions, disable the platform JWT check for the function in `supabase/config.toml` so the OAuth discovery route can be answered without a token. `withSupabase({ auth: 'user' })` still verifies every other request: + +```toml +[functions.mcp] +verify_jwt = false +``` + +## What gets generated + +`generateTools` returns a record keyed by tool name. Each entry is the argument shape `McpServer.registerTool()` takes — `description`, `inputSchema`, `annotations` — plus the `handler` that runs it and `_meta` recording where it came from. + +| Tool | Generated when | Arguments | +| ------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------- | +| `list_` | the relation supports `GET` | one equality filter per column, plus `order` (`column` or `column.desc`), `limit`, `offset` | +| `get_` | `GET` and the relation has a primary key | the primary-key columns (all of them, for a composite key) | +| `create_` | `POST` | every column; required = `NOT NULL` columns without a default, excluding the primary key | +| `update_` | `PATCH` and a primary key | every column; the primary key is required and identifies the row, the rest are the changes | +| `delete_` | `DELETE` and a primary key | the primary-key columns | +| `` | the function is exposed at `/rpc/` | the function's arguments | + +Names carry no prefix — `list_notes` beats `postgrest_list_notes` when a model chooses between tools. Two operations that produce the same name (a function called `list_notes` next to a table `notes`) fail generation with [`TOOL_NAME_COLLISION`](error-handling.md#tool_name_collision) rather than silently replacing one. + +Relations and functions are read from the schema the client was created with, so the tools describe exactly what they execute against. Views are relations too; PostgREST carries the base table's primary key over, so a simple view gets the same five tools as a table. + +### Descriptions come from the database + +`COMMENT ON` is the authoring surface. PostgREST includes comments in the description it publishes, so there is nothing else to configure: + +```sql +comment on table expenses is 'Expense claims submitted by staff.'; +comment on column expenses.memo is 'Short free-text justification.'; + +comment on function submit_expense is + 'Submit an expense for the signed-in user. Amounts over 500 require a manager.'; +``` + +A table comment leads the description of every tool for that table; a column comment becomes the description of that argument; a function comment is the function tool's description. Where no comment exists, generation falls back to a plain sentence built from the operation and the name. It works, but a model choosing between tools is reading these strings — write the comments. + +Column types are included in each argument's description (`Postgres type: uuid`, `Default: auth.uid()`), never as JSON Schema `format` or `default` keywords: PostgREST's type names vary by version, and a SQL default such as `now()` is not a JSON default. + +### Annotations + +| Operation | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` | +| ------------------------------- | -------------- | ----------------- | ---------------- | --------------- | +| `list_`, `get_` | `true` | `false` | `true` | `false` | +| `create_` | `false` | `false` | `false` | `false` | +| `update_` | `false` | `true` | `false` | `false` | +| `delete_` | `false` | `true` | `true` | `false` | +| function with `GET` on `/rpc/…` | `true` | `false` | `true` | `false` | +| function with `POST` only | `false` | `true` | `false` | `true` | + +PostgREST exposes `GET /rpc/` only for `IMMUTABLE` and `STABLE` functions, which is what makes the read-only inference safe. A `VOLATILE` function may do anything — including reaching outside the project through an extension such as `pg_net` — so it alone is marked open-world. + +Annotations are advisory. They do not replace grants, Row Level Security, or application-level authorization. + +## Who sees which tools + +The description is fetched with the caller's token, so it reflects the role in the JWT — on a normal project, `authenticated` for every signed-in user. **Every signed-in caller therefore sees the same tool set.** Row Level Security decides which rows a tool returns when it runs; it does not change the description. + +Two things PostgREST does that are worth knowing: + +- **Verbs are not filtered by grants.** A table the role may only `SELECT` from is still described with `POST`, `PATCH` and `DELETE`, so `create_`, `update_` and `delete_` tools are generated for it. Calling one fails with PostgREST's `permission denied` error, which the tool reports. Remove the entry before registration if you do not want the tool offered at all (see below). +- **Which relations appear depends on PostgREST's `openapi-mode`.** With `follow-privileges`, a relation the role has no privilege on at all is left out of the description. The Supabase CLI's local stack lists every relation in the exposed schemas regardless of role. + +Either way, grants and RLS are enforced by Postgres when a tool runs, not by this package. + +## Customizing and filtering + +Generation returns ordinary objects, so plain JavaScript is enough. `tool.name` is authoritative for registration; the record key is an index, so none of this can accidentally rename a tool. + +Replace a generated tool's implementation: + +```ts +tools.get_notes.handler = async ({ id }) => { + const { data, error } = await supabase + .from('notes') + .select('*') + .eq('id', id) + .single() + if (error) throw error + return { content: [{ type: 'text', text: JSON.stringify(data) }] } +} +``` + +Exclude a tool, or register a subset: + +```ts +delete tools.delete_notes + +registerTools(server, { list_notes: tools.list_notes }) +``` + +Filter on `_meta` — every tool backed by one table, or only the function-backed tools: + +```ts +const noteTools = Object.fromEntries( + Object.entries(tools).filter(([, tool]) => tool._meta.name === 'notes'), +) + +const functionTools = Object.fromEntries( + Object.entries(tools).filter(([, tool]) => tool._meta.kind === 'function'), +) + +registerTools(server, functionTools) +``` + +Hand-written tools go straight onto the SDK, next to the generated ones. If a hand-written tool shares a name with a generated one, replace or remove the generated entry first — the SDK refuses to register the same name twice. + +## Errors + +Both are [`ToolGenerationError`](error-handling.md#toolgenerationerror-codes)s with `status: 500`: + +| Code | Cause | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`SPEC_FETCH_FAILED`](error-handling.md#spec_fetch_failed) | The description could not be read: PostgREST answered 404/406 (OpenAPI output disabled), 401/403 (credentials), or the body is not a Swagger 2.0 document. Also raised when the supabase-js client predates `getOpenApiSpec()`. | +| [`TOOL_NAME_COLLISION`](error-handling.md#tool_name_collision) | Two operations produce the same tool name. `details` carries the name and both operations. | + +Errors thrown by a tool's handler — PostgREST rejections, a missing row — are turned into tool errors by the MCP SDK, so the model sees PostgREST's message, details, hint and code. + +## Known limitations + +- The description is read on every call to `generateTools`, so a server that generates per request pays for one extra PostgREST request per MCP message. No caching yet. +- A `create_` tool asks for every required column except primary keys and columns with defaults. A table whose primary key is a natural text code rather than an identity column fails on create until the tool is overridden. +- A function tool cannot document its individual arguments; Postgres has nowhere to store that text. +- Generated names are the relation and function names as-is. MCP tool names must match `[A-Za-z0-9._-]` and stay under 128 characters; a relation name outside that is rejected by the SDK at registration. +- `list_` filters are equality only, and columns literally named `order`, `limit` or `offset` cannot be filtered on. Advanced filters, joins and full-text search are written by hand. +- Results are JSON text. No `outputSchema` or `structuredContent` yet. +- The tool list is a contract with the client, and schema changes break it silently: drop a table and its tools disappear on the next `tools/list`; rename one and its tools are renamed. Treat a relation or function an agent depends on the way you would treat a public API — add the new one, migrate, then remove the old. diff --git a/e2e/supabase/migrations/20260102000000_mcp_probe.sql b/e2e/supabase/migrations/20260102000000_mcp_probe.sql new file mode 100644 index 0000000..18f37a2 --- /dev/null +++ b/e2e/supabase/migrations/20260102000000_mcp_probe.sql @@ -0,0 +1,109 @@ +-- Probe schema for @supabase/server/mcp tool generation. Every object here +-- produces one case in src/mcp/__fixtures__/probe-spec.ts: the Swagger 2.0 +-- document PostgREST serves for the `authenticated` role. See that file's +-- header for the capture command. The adapter scenarios do not touch these. + +-- Identity primary key (required in the description, no default, so excluded +-- from create_), a NOT NULL column with `default auth.uid()`, a `default false` +-- boolean, a nullable column, and comments on the table and on one column. +create table public.tasks ( + id bigint generated always as identity primary key, + owner_id uuid not null default auth.uid() references auth.users (id) on delete cascade, + title text not null, + notes text, + done boolean not null default false, + created_at timestamptz not null default now() +); +comment on table public.tasks is 'A user''s to-do items.'; +comment on column public.tasks.title is 'Short label shown in the list.'; + +-- Composite primary key and no comments: fallback descriptions, and get_ / +-- delete_ require both key columns. +create table public.task_tags ( + task_id bigint not null references public.tasks (id) on delete cascade, + tag text not null, + primary key (task_id, tag) +); + +-- View: PostgREST carries the base table's primary key over to the view's `id` +-- column, so the view gets the same five relation tools as a table. +create view public.open_tasks with (security_invoker = true) as + select id, title, created_at from public.tasks where not done; + +-- STABLE function with a comment: PostgREST exposes GET and POST, so the tool +-- is inferred read-only and the comment becomes its description. +create function public.task_summary(p_done boolean default false) +returns setof public.tasks +language sql stable security invoker +set search_path = '' +as $$ select * from public.tasks where done = p_done $$; +comment on function public.task_summary(boolean) is + 'Tasks filtered by completion state, scoped to the caller.'; + +-- VOLATILE function without a comment: POST only, fallback description, and +-- conservative annotations (openWorldHint: true). +create function public.complete_task(p_id bigint) +returns public.tasks +language sql volatile security invoker +set search_path = '' +as $$ update public.tasks set done = true where id = p_id returning * $$; + +-- Deliberate collision: a function named exactly like the generated list tool +-- for `tasks`. Generation must fail with TOOL_NAME_COLLISION, never overwrite. +create function public.list_tasks() +returns setof public.tasks +language sql stable security invoker +set search_path = '' +as $$ select * from public.tasks $$; + +alter table public.tasks enable row level security; +alter table public.task_tags enable row level security; + +create policy "tasks: owner select" on public.tasks + for select to authenticated + using ((select auth.uid()) = owner_id); +create policy "tasks: owner insert" on public.tasks + for insert to authenticated + with check ((select auth.uid()) = owner_id); +create policy "tasks: owner update" on public.tasks + for update to authenticated + using ((select auth.uid()) = owner_id) + with check ((select auth.uid()) = owner_id); +create policy "tasks: owner delete" on public.tasks + for delete to authenticated + using ((select auth.uid()) = owner_id); +create policy "task_tags: via task" on public.task_tags + for all to authenticated + using (exists ( + select 1 from public.tasks t + where t.id = task_id and t.owner_id = (select auth.uid()) + )) + with check (exists ( + select 1 from public.tasks t + where t.id = task_id and t.owner_id = (select auth.uid()) + )); + +-- PostgREST lists every verb a relation supports regardless of grants; grants +-- and RLS are enforced when a tool runs. task_tags has no UPDATE grant, so +-- update_task_tags is still generated and fails with `permission denied`. +grant select, insert, update, delete on public.tasks to authenticated; +grant select, insert, delete on public.task_tags to authenticated; +grant select on public.open_tasks to authenticated; +grant execute on function + public.task_summary(boolean), + public.complete_task(bigint), + public.list_tasks() +to authenticated; + +-- No primary key: list_ and create_ are generated, get_ / update_ / delete_ +-- are not. +create table public.audit_log ( + occurred_at timestamptz not null default now(), + message text not null +); +alter table public.audit_log enable row level security; +create policy "audit_log: authenticated read" on public.audit_log + for select to authenticated using (true); +create policy "audit_log: authenticated insert" on public.audit_log + for insert to authenticated with check (true); +grant select, insert on public.audit_log to authenticated; diff --git a/jsr.json b/jsr.json index d996606..ed4e573 100644 --- a/jsr.json +++ b/jsr.json @@ -15,17 +15,15 @@ "./middleware/postgres-admin": "./src/middleware/postgres-admin/index.ts", "./middleware/claims": "./src/middleware/claims/index.ts", "./middleware/required-claims": "./src/middleware/required-claims/index.ts", - "./oauth-protected-resource": "./src/oauth-protected-resource/index.ts" + "./oauth-protected-resource": "./src/oauth-protected-resource/index.ts", + "./mcp": "./src/mcp/index.ts" }, "publish": { - "include": [ - "src/**/*.ts", - "README.md", - "LICENSE" - ], + "include": ["src/**/*.ts", "README.md", "LICENSE"], "exclude": [ "src/**/*.test.ts", - "src/**/*.spec.ts" + "src/**/*.spec.ts", + "src/**/__fixtures__/**" ] } } diff --git a/package.json b/package.json index af16b44..95e203b 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,16 @@ "default": "./dist/oauth-protected-resource/index.cjs" } }, + "./mcp": { + "import": { + "types": "./dist/mcp/index.d.mts", + "default": "./dist/mcp/index.mjs" + }, + "require": { + "types": "./dist/mcp/index.d.cts", + "default": "./dist/mcp/index.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", @@ -198,6 +208,7 @@ "commit-msg": "pnpm commitlint --edit \"$1\"" }, "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", "@nestjs/common": "^10.0.0 || ^11.0.0", "@supabase/supabase-js": "^2.0.0", "elysia": "^1.4.0", @@ -220,18 +231,22 @@ }, "pg": { "optional": true + }, + "@modelcontextprotocol/server": { + "optional": true } }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.4", "@commitlint/cli": "^20.4.2", "@commitlint/config-conventional": "^20.4.2", + "@modelcontextprotocol/server": "^2.0.0", "@nestjs/common": "^11.1.19", "@nestjs/core": "^11.1.19", "@nestjs/platform-express": "^11.1.19", "@nestjs/platform-fastify": "^11.1.19", "@nestjs/testing": "^11.1.19", - "@supabase/supabase-js": "^2.114.0", + "@supabase/supabase-js": "^2.115.0", "@swc/core": "^1.15.33", "@types/node": "^26.0.1", "@types/pg": "^8.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 196d638..91567e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@commitlint/config-conventional': specifier: ^20.4.2 version: 20.4.2 + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.0.0 '@nestjs/common': specifier: ^11.1.19 version: 11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -43,8 +46,8 @@ importers: specifier: ^11.1.19 version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19) '@supabase/supabase-js': - specifier: ^2.114.0 - version: 2.114.0 + specifier: ^2.115.0 + version: 2.115.0 '@swc/core': specifier: ^1.15.33 version: 1.15.33 @@ -465,6 +468,14 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -822,12 +833,15 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} - '@supabase/auth-js@2.114.0': - resolution: {integrity: sha512-7pdAE31YHynM1b2rbrbVtQc4ug5LcUvATe9u3EazlCZMY6jar7Z5JHJlj7/qcO5Z6KAYT26sTr5mvVHePeuC/g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@supabase/auth-js@2.115.0': + resolution: {integrity: sha512-YNQlQWm1H0gsXHSY8Jd/xepBhjO0Zhwx04iW17A83/joQ5kFiUin6iPj9s9kZZvupnwLjXVP/diTFiLz2jUbwQ==} engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.114.0': - resolution: {integrity: sha512-N3hzlq2xr6IYkkgj0tC4j0U3LWVshr5U/DAvmD5Bu8bW1ne2XOwxOx3ps6Y3NbptBAlzDrezmeJevlZR4AqsLA==} + '@supabase/functions-js@2.115.0': + resolution: {integrity: sha512-p97V6/YFcdp+zblFDVJaE8f9rGKTNz0PRzyJ2d1w/EYIU5lwidKuc3l/wM+u27ACmAC62albvoGiUGzLPSg7Aw==} engines: {node: '>=22.0.0'} '@supabase/middleware@0.5.0': @@ -842,20 +856,20 @@ packages: '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - '@supabase/postgrest-js@2.114.0': - resolution: {integrity: sha512-yKAe5Tc47+LLm6YU1OAHWqwkxidlnl/vHDMHx+VANcUaeMfNzDKrXTUuZ5BKCGM4+1gJWm/U3n4W+2aRn8d8dA==} + '@supabase/postgrest-js@2.115.0': + resolution: {integrity: sha512-DdERcurLh5t84pgSywDg2LjLR5le7XAzl52/iQhC7FdboWDqGfDPMfaWJV3MHvhyxSlSNzbpSQG+RiQOBSn/4w==} engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.114.0': - resolution: {integrity: sha512-gSkuQrM/etAlUnw9YMDA2e+iNMCLezf0IHyOzmetOqy9KPsGe2C3puTd/4xalirnko+p3BEykFVliHaY2yXVrw==} + '@supabase/realtime-js@2.115.0': + resolution: {integrity: sha512-5HyBkvlA/IUV2v8jX3uLTdy15jmTPRVXwXKoxvH2SKw5jZMNURxCxt+VpCEukscXQlY7pUpH8Cy4NH6io4iaRw==} engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.114.0': - resolution: {integrity: sha512-leK7YsDVqIKd9hQRJeK03QRZ8Slg2JExmNIj+BL7dkD3m+ZNREJ7ITQdrc7VhNhh8+Eb/ebm1Ts4AOoY4LFSiA==} + '@supabase/storage-js@2.115.0': + resolution: {integrity: sha512-dLyIxzbO+MCcKHhcce8rVUCQX1iyqXqQ8ytgkOVYJ7D+Zp0qKylPtQH3hamgxrGSxtDjaw47Urpzw2iK9PsKdA==} engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.114.0': - resolution: {integrity: sha512-uvmqk2yxVp77c/LjWzJaw1/HId+2a3sck4idbBy3nOTro36l7nVcHdT/XENHj7Fi/IJAvAsJrTSgTEegbYTxvQ==} + '@supabase/supabase-js@2.115.0': + resolution: {integrity: sha512-PYJSxtCo37R7tTZW6pAqsxUeSx/dlhA7zn8RzKEUSCqyTxCUhG+iHrDTb04UyVBYbttaUbhwkNTvb8h5HW+uZA==} engines: {node: '>=22.0.0'} peerDependencies: '@opentelemetry/api': '>=1.0.0' @@ -2875,6 +2889,9 @@ packages: yuku-parser@0.8.7: resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@andrewbranch/untar.js@1.0.3': {} @@ -3209,6 +3226,15 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.4.3 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -3444,11 +3470,13 @@ snapshots: '@sindresorhus/is@4.6.0': {} - '@supabase/auth-js@2.114.0': + '@standard-schema/spec@1.1.0': {} + + '@supabase/auth-js@2.115.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.114.0': + '@supabase/functions-js@2.115.0': dependencies: tslib: 2.8.1 @@ -3460,27 +3488,27 @@ snapshots: '@supabase/phoenix@0.4.5': {} - '@supabase/postgrest-js@2.114.0': + '@supabase/postgrest-js@2.115.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.114.0': + '@supabase/realtime-js@2.115.0': dependencies: '@supabase/phoenix': 0.4.5 tslib: 2.8.1 - '@supabase/storage-js@2.114.0': + '@supabase/storage-js@2.115.0': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.114.0': + '@supabase/supabase-js@2.115.0': dependencies: - '@supabase/auth-js': 2.114.0 - '@supabase/functions-js': 2.114.0 - '@supabase/postgrest-js': 2.114.0 - '@supabase/realtime-js': 2.114.0 - '@supabase/storage-js': 2.114.0 + '@supabase/auth-js': 2.115.0 + '@supabase/functions-js': 2.115.0 + '@supabase/postgrest-js': 2.115.0 + '@supabase/realtime-js': 2.115.0 + '@supabase/storage-js': 2.115.0 '@swc/core-darwin-arm64@1.15.33': optional: true @@ -5388,3 +5416,5 @@ snapshots: '@yuku-parser/binding-linux-x64-musl': 0.8.7 '@yuku-parser/binding-win32-arm64': 0.8.7 '@yuku-parser/binding-win32-x64': 0.8.7 + + zod@4.4.3: {} diff --git a/scripts/check-declared-deps.mjs b/scripts/check-declared-deps.mjs index b611175..0777963 100644 --- a/scripts/check-declared-deps.mjs +++ b/scripts/check-declared-deps.mjs @@ -32,10 +32,13 @@ const walk = (dir) => return statSync(path).isDirectory() ? walk(path) : [path] }) -// Mirrors the `exclude` list in jsr.json: tests are not part of the payload, so -// they cannot break the graph. +// Mirrors the `exclude` list in jsr.json: tests and fixtures are not part of the +// payload, so they cannot break the graph. const published = walk(join(root, 'src')).filter( - (file) => file.endsWith('.ts') && !/\.(test|spec)\.ts$/.test(file), + (file) => + file.endsWith('.ts') && + !/\.(test|spec)\.ts$/.test(file) && + !file.includes('/__fixtures__/'), ) function specifiers(file) { diff --git a/scripts/smoke-pack.mjs b/scripts/smoke-pack.mjs index efab6b1..b832f57 100644 --- a/scripts/smoke-pack.mjs +++ b/scripts/smoke-pack.mjs @@ -36,6 +36,7 @@ const ALLOWED_OPTIONAL_PEERS = { './adapters/nestjs': ['@nestjs/common'], './middleware/postgres': ['pg'], './middleware/postgres-admin': ['pg'], + './mcp': ['@modelcontextprotocol/server'], } // A missing subpath export reports as `hono/factory`; the allowance is `hono`. diff --git a/skills/supabase-server/SKILL.md b/skills/supabase-server/SKILL.md index 9cbb266..7764ca7 100644 --- a/skills/supabase-server/SKILL.md +++ b/skills/supabase-server/SKILL.md @@ -34,11 +34,12 @@ Server-side utilities for Supabase. Handles auth, client creation, and context i ## Entry points -| Import | Deno / Edge Functions | Provides | -| -------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | -| `@supabase/server` | `npm:@supabase/server` | `withSupabase`, `createSupabaseContext`, types, errors | -| `@supabase/server/core` | `npm:@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `resolveEnv`, `createContextClient`, `createAdminClient` | -| `@supabase/server/adapters/hono` | `npm:@supabase/server/adapters/hono` | `withSupabase` (Hono middleware variant) | +| Import | Deno / Edge Functions | Provides | +| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@supabase/server` | `npm:@supabase/server` | `withSupabase`, `createSupabaseContext`, types, errors | +| `@supabase/server/core` | `npm:@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `resolveEnv`, `createContextClient`, `createAdminClient` | +| `@supabase/server/adapters/hono` | `npm:@supabase/server/adapters/hono` | `withSupabase` (Hono middleware variant) | +| `@supabase/server/mcp` | `npm:@supabase/server/mcp` | **Alpha.** `generateTools`, `registerTools` — MCP tools generated from the PostgREST schema (needs `@modelcontextprotocol/server` 2.x, supabase-js 2.115.0+) | ## Quick starts @@ -424,6 +425,7 @@ The full documentation lives in the `docs/` directory of the `@supabase/server` | How do environment variables work across runtimes? | `docs/environment-variables.md` | | How do I handle errors? What codes exist? | `docs/error-handling.md` | | How do I get typed database queries? | `docs/typescript-generics.md` | +| How do I generate MCP tools from my schema? | `docs/mcp.md` | | How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | `docs/ssr-frameworks.md` | | What's the complete API surface? | `docs/api-reference.md` | | What security decisions does this package make? | `docs/security.md` | diff --git a/src/errors.test.ts b/src/errors.test.ts index b8472aa..1a0bb6c 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -9,7 +9,11 @@ import { MissingDefaultSecretKeyError, MissingSecretKeyError, MissingSupabaseURLError, + SpecFetchFailedError, SupabaseServerError, + ToolGenerationError, + ToolGenerationGenericError, + ToolNameCollisionError, } from './errors.js' describe('SupabaseServerError', () => { @@ -140,3 +144,58 @@ describe('Errors factory map', () => { expect(Errors[InvalidCredentialsError]().status).toBe(401) }) }) + +describe('ToolGenerationError', () => { + it('is a SupabaseServerError that is always a 500', () => { + const error = new ToolGenerationError('nope') + expect(error).toBeInstanceOf(SupabaseServerError) + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(EnvError) + expect(error).not.toBeInstanceOf(AuthError) + expect(error.name).toBe('ToolGenerationError') + expect(error.status).toBe(500) + expect(error.code).toBe(ToolGenerationGenericError) + expect(error.message).toBe('[@supabase/server] nope') + }) + + it('builds a SPEC_FETCH_FAILED error for each failure shape', () => { + const unsupported = Errors[SpecFetchFailedError]({ + reason: 'unsupported-client', + }) + expect(unsupported.code).toBe(SpecFetchFailedError) + expect(unsupported.hint).toContain('2.115.0') + expect(unsupported.docs).toBe( + 'https://github.com/supabase/server/blob/main/docs/error-handling.md#spec_fetch_failed', + ) + + const cause = new Error('boom') + const request = Errors[SpecFetchFailedError]({ + reason: 'request', + status: 404, + message: 'Not Found', + cause, + }) + expect(request.message).toContain('(HTTP 404): Not Found') + expect(request.details).toEqual({ status: 404 }) + expect(request.cause).toBe(cause) + + const malformed = Errors[SpecFetchFailedError]({ reason: 'malformed' }) + expect(malformed.details).toBeUndefined() + expect(malformed.toJSON()).not.toHaveProperty('details') + }) + + it('builds a TOOL_NAME_COLLISION error naming both operations', () => { + const error = Errors[ToolNameCollisionError]({ + name: 'list_notes', + operations: ['relation "notes" (GET)', 'function "list_notes" (POST)'], + }) + expect(error.code).toBe(ToolNameCollisionError) + expect(error.message).toBe( + '[@supabase/server] Two operations produce the tool name "list_notes": relation "notes" (GET) and function "list_notes" (POST).', + ) + expect(error.details).toEqual({ + name: 'list_notes', + operations: ['relation "notes" (GET)', 'function "list_notes" (POST)'], + }) + }) +}) diff --git a/src/errors.ts b/src/errors.ts index 16092dd..36b93a6 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -979,6 +979,153 @@ const AuthErrorMap = { }, } +/** + * Thrown when MCP tools cannot be generated from the PostgREST description. + * + * Always has `status: 500` — the description could not be read, or the schema + * itself produces two tools with the same name. Neither is the caller's fault. + * + * @example Catching a ToolGenerationError + * ```ts + * import { ToolGenerationError } from '@supabase/server' + * import { generateTools } from '@supabase/server/mcp' + * + * try { + * const tools = await generateTools(supabase) + * } catch (e) { + * if (e instanceof ToolGenerationError) { + * console.error(`[${e.code}] ${e.message}\n${e.hint}`) + * } + * } + * ``` + * + * @category Errors + */ +export class ToolGenerationError extends SupabaseServerError { + /** Always `500` — tool generation fails server-side. */ + readonly status = 500 + + /** + * @param message - Human-readable description. Prefixed with `[@supabase/server]`. + * @param code - Machine-readable code. @see {@link SpecFetchFailedError}, + * {@link ToolNameCollisionError} + * @param options - Optional `hint`, `details`, `docs`, and `cause`. + */ + constructor( + message: string, + code = ToolGenerationGenericError, + options?: SupabaseServerErrorOptions, + ) { + super(message, code, options) + this.name = 'ToolGenerationError' + } +} + +/** + * Generic tool generation error code. + * @category Errors + */ +export const ToolGenerationGenericError = 'TOOL_GENERATION_ERROR' + +/** + * The PostgREST OpenAPI description could not be fetched, or the response is + * not a Swagger 2.0 document. + * @category Errors + */ +export const SpecFetchFailedError = 'SPEC_FETCH_FAILED' + +/** + * Two generated operations produce the same tool name. + * @category Errors + */ +export const ToolNameCollisionError = 'TOOL_NAME_COLLISION' + +/** + * Why the PostgREST description could not be read. Passed to + * `Errors[SpecFetchFailedError]`. + * + * @category Errors + */ +export type SpecFetchFailure = + /** The supabase-js client predates `getOpenApiSpec()` (2.115.0). */ + | { reason: 'unsupported-client' } + /** PostgREST answered with an error, or the request never arrived (`status: 0`). */ + | { reason: 'request'; status: number; message: string; cause?: unknown } + /** The response is not a Swagger 2.0 document with `definitions`. */ + | { reason: 'malformed'; status?: number } + +/** @internal */ +function specFetchHint(status: number): string { + if (status === 404 || status === 406) { + return ( + `PostgREST returned ${status}, which usually means OpenAPI output is disabled on the project's Data API ` + + '(openapi-mode), or the client URL does not point at a PostgREST endpoint.' + ) + } + if (status === 401 || status === 403) { + return ( + 'PostgREST rejected the credentials. The Supabase client must carry a valid API key and, ' + + "for caller-scoped generation, the caller's access token." + ) + } + if (status === 0) { + return 'The request never reached PostgREST. Check the project URL and network connectivity.' + } + return 'Check that the project URL points at a healthy Data API.' +} + +const ToolGenerationErrorMap = { + [SpecFetchFailedError]: (failure: SpecFetchFailure): ToolGenerationError => { + switch (failure.reason) { + case 'unsupported-client': + return new ToolGenerationError( + 'Cannot fetch the PostgREST OpenAPI description: this @supabase/supabase-js client has no getOpenApiSpec().', + SpecFetchFailedError, + { hint: 'Upgrade @supabase/supabase-js to 2.115.0 or newer.' }, + ) + case 'request': + return new ToolGenerationError( + `Could not fetch the PostgREST OpenAPI description (HTTP ${failure.status}): ${failure.message}`, + SpecFetchFailedError, + { + hint: specFetchHint(failure.status), + details: { status: failure.status }, + cause: failure.cause, + }, + ) + case 'malformed': + return new ToolGenerationError( + 'The PostgREST OpenAPI description is not a Swagger 2.0 document with definitions.', + SpecFetchFailedError, + { + hint: + 'PostgREST serves Swagger 2.0 at the REST root. A response without "swagger" and "definitions" ' + + 'usually means OpenAPI output is disabled on the project, or another service answered.', + details: + failure.status === undefined + ? undefined + : { status: failure.status }, + }, + ) + } + }, + + [ToolNameCollisionError]: (context: { + name: string + operations: readonly string[] + }): ToolGenerationError => + new ToolGenerationError( + `Two operations produce the tool name "${context.name}": ${context.operations.join(' and ')}.`, + ToolNameCollisionError, + { + hint: + 'Tool names are one namespace across tables, views and functions. Rename the database function, ' + + "or revoke the role's privilege on one of the two so it leaves the description.", + details: { name: context.name, operations: [...context.operations] }, + }, + ), +} + /** * Returns a copy of `error` carrying an extra leading hint sentence and merged * `details`. Lets an outer layer add diagnostics the inner layer could not see — @@ -1005,7 +1152,8 @@ export function withExtraDiagnostics( /** * Factory map for all error types. Keyed by error code constant, each entry - * returns a pre-configured {@link EnvError} or {@link AuthError} complete with + * returns a pre-configured {@link EnvError}, {@link AuthError} or + * {@link ToolGenerationError} complete with * `hint`, `docs`, and `details`. * * @example Throwing typed errors @@ -1019,4 +1167,5 @@ export function withExtraDiagnostics( export const Errors = { ...EnvErrorMap, ...AuthErrorMap, + ...ToolGenerationErrorMap, } diff --git a/src/index.ts b/src/index.ts index a4e039b..57b5dfd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -147,7 +147,11 @@ export { MissingSecretKeyError, MissingSupabaseURLError, NoKeysConfiguredError, + SpecFetchFailedError, SupabaseServerError, + ToolGenerationError, + ToolGenerationGenericError, + ToolNameCollisionError, UnsupportedRoleError, UnusableCredentialError, } from './errors.js' @@ -158,5 +162,6 @@ export type { ErrorPayload, MinimalErrorPayload, ReceivedCredentials, + SpecFetchFailure, SupabaseServerErrorOptions, } from './errors.js' diff --git a/src/mcp/__fixtures__/probe-spec.ts b/src/mcp/__fixtures__/probe-spec.ts new file mode 100644 index 0000000..43a5bea --- /dev/null +++ b/src/mcp/__fixtures__/probe-spec.ts @@ -0,0 +1,1170 @@ +// Swagger 2.0 document captured from PostgREST 16.1 in the e2e stack +// (Supabase CLI 2.115.0) as the `authenticated` role, with the probe schema in +// e2e/supabase/migrations/20260102000000_mcp_probe.sql applied. Shapes here +// were read off a running PostgREST, not inferred. Regenerate with: +// +// (cd e2e && supabase start) && pnpm gen:env +// set -a && . ./e2e/.env && set +a && TOKEN=$(node e2e/scripts/get-token.ts) +// curl -s "$SUPABASE_URL/rest/v1/" -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ +// -H "Authorization: Bearer $TOKEN" -H "Accept: application/openapi+json" +// +// then paste the JSON into `probeSpecWithCollision` and run prettier. +import type { PostgrestOpenApiSpec } from '@supabase/supabase-js' + +/** + * The raw capture. It includes the database function `list_tasks`, whose tool + * name collides with the generated `list_tasks` for the `tasks` table. + */ +export const probeSpecWithCollision: PostgrestOpenApiSpec = { + swagger: '2.0', + info: { + description: '', + title: 'standard public schema', + version: '16.1', + }, + host: '0.0.0.0:3000', + basePath: '/', + schemes: ['http'], + consumes: [ + 'application/json', + 'application/vnd.pgrst.object+json;nulls=stripped', + 'application/vnd.pgrst.object+json', + 'text/csv', + ], + produces: [ + 'application/json', + 'application/vnd.pgrst.object+json;nulls=stripped', + 'application/vnd.pgrst.object+json', + 'text/csv', + ], + paths: { + '/': { + get: { + produces: ['application/openapi+json', 'application/json'], + responses: { + '200': { + description: 'OK', + }, + }, + summary: 'OpenAPI description (this document)', + tags: ['Introspection'], + }, + }, + '/open_tasks': { + get: { + parameters: [ + { + $ref: '#/parameters/rowFilter.open_tasks.id', + }, + { + $ref: '#/parameters/rowFilter.open_tasks.title', + }, + { + $ref: '#/parameters/rowFilter.open_tasks.created_at', + }, + { + $ref: '#/parameters/select', + }, + { + $ref: '#/parameters/order', + }, + { + $ref: '#/parameters/range', + }, + { + $ref: '#/parameters/rangeUnit', + }, + { + $ref: '#/parameters/offset', + }, + { + $ref: '#/parameters/limit', + }, + { + $ref: '#/parameters/preferCount', + }, + ], + responses: { + '200': { + description: 'OK', + schema: { + items: { + $ref: '#/definitions/open_tasks', + }, + type: 'array', + }, + }, + '206': { + description: 'Partial Content', + }, + }, + tags: ['open_tasks'], + }, + post: { + parameters: [ + { + $ref: '#/parameters/body.open_tasks', + }, + { + $ref: '#/parameters/select', + }, + { + $ref: '#/parameters/preferPost', + }, + ], + responses: { + '201': { + description: 'Created', + }, + }, + tags: ['open_tasks'], + }, + delete: { + parameters: [ + { + $ref: '#/parameters/rowFilter.open_tasks.id', + }, + { + $ref: '#/parameters/rowFilter.open_tasks.title', + }, + { + $ref: '#/parameters/rowFilter.open_tasks.created_at', + }, + { + $ref: '#/parameters/preferReturn', + }, + ], + responses: { + '204': { + description: 'No Content', + }, + }, + tags: ['open_tasks'], + }, + patch: { + parameters: [ + { + $ref: '#/parameters/rowFilter.open_tasks.id', + }, + { + $ref: '#/parameters/rowFilter.open_tasks.title', + }, + { + $ref: '#/parameters/rowFilter.open_tasks.created_at', + }, + { + $ref: '#/parameters/body.open_tasks', + }, + { + $ref: '#/parameters/preferReturn', + }, + ], + responses: { + '204': { + description: 'No Content', + }, + }, + tags: ['open_tasks'], + }, + }, + '/audit_log': { + get: { + parameters: [ + { + $ref: '#/parameters/rowFilter.audit_log.occurred_at', + }, + { + $ref: '#/parameters/rowFilter.audit_log.message', + }, + { + $ref: '#/parameters/select', + }, + { + $ref: '#/parameters/order', + }, + { + $ref: '#/parameters/range', + }, + { + $ref: '#/parameters/rangeUnit', + }, + { + $ref: '#/parameters/offset', + }, + { + $ref: '#/parameters/limit', + }, + { + $ref: '#/parameters/preferCount', + }, + ], + responses: { + '200': { + description: 'OK', + schema: { + items: { + $ref: '#/definitions/audit_log', + }, + type: 'array', + }, + }, + '206': { + description: 'Partial Content', + }, + }, + tags: ['audit_log'], + }, + post: { + parameters: [ + { + $ref: '#/parameters/body.audit_log', + }, + { + $ref: '#/parameters/select', + }, + { + $ref: '#/parameters/preferPost', + }, + ], + responses: { + '201': { + description: 'Created', + }, + }, + tags: ['audit_log'], + }, + delete: { + parameters: [ + { + $ref: '#/parameters/rowFilter.audit_log.occurred_at', + }, + { + $ref: '#/parameters/rowFilter.audit_log.message', + }, + { + $ref: '#/parameters/preferReturn', + }, + ], + responses: { + '204': { + description: 'No Content', + }, + }, + tags: ['audit_log'], + }, + patch: { + parameters: [ + { + $ref: '#/parameters/rowFilter.audit_log.occurred_at', + }, + { + $ref: '#/parameters/rowFilter.audit_log.message', + }, + { + $ref: '#/parameters/body.audit_log', + }, + { + $ref: '#/parameters/preferReturn', + }, + ], + responses: { + '204': { + description: 'No Content', + }, + }, + tags: ['audit_log'], + }, + }, + '/notes': { + get: { + parameters: [ + { + $ref: '#/parameters/rowFilter.notes.id', + }, + { + $ref: '#/parameters/rowFilter.notes.user_id', + }, + { + $ref: '#/parameters/rowFilter.notes.body', + }, + { + $ref: '#/parameters/rowFilter.notes.created_at', + }, + { + $ref: '#/parameters/select', + }, + { + $ref: '#/parameters/order', + }, + { + $ref: '#/parameters/range', + }, + { + $ref: '#/parameters/rangeUnit', + }, + { + $ref: '#/parameters/offset', + }, + { + $ref: '#/parameters/limit', + }, + { + $ref: '#/parameters/preferCount', + }, + ], + responses: { + '200': { + description: 'OK', + schema: { + items: { + $ref: '#/definitions/notes', + }, + type: 'array', + }, + }, + '206': { + description: 'Partial Content', + }, + }, + tags: ['notes'], + }, + post: { + parameters: [ + { + $ref: '#/parameters/body.notes', + }, + { + $ref: '#/parameters/select', + }, + { + $ref: '#/parameters/preferPost', + }, + ], + responses: { + '201': { + description: 'Created', + }, + }, + tags: ['notes'], + }, + delete: { + parameters: [ + { + $ref: '#/parameters/rowFilter.notes.id', + }, + { + $ref: '#/parameters/rowFilter.notes.user_id', + }, + { + $ref: '#/parameters/rowFilter.notes.body', + }, + { + $ref: '#/parameters/rowFilter.notes.created_at', + }, + { + $ref: '#/parameters/preferReturn', + }, + ], + responses: { + '204': { + description: 'No Content', + }, + }, + tags: ['notes'], + }, + patch: { + parameters: [ + { + $ref: '#/parameters/rowFilter.notes.id', + }, + { + $ref: '#/parameters/rowFilter.notes.user_id', + }, + { + $ref: '#/parameters/rowFilter.notes.body', + }, + { + $ref: '#/parameters/rowFilter.notes.created_at', + }, + { + $ref: '#/parameters/body.notes', + }, + { + $ref: '#/parameters/preferReturn', + }, + ], + responses: { + '204': { + description: 'No Content', + }, + }, + tags: ['notes'], + }, + }, + '/task_tags': { + get: { + parameters: [ + { + $ref: '#/parameters/rowFilter.task_tags.task_id', + }, + { + $ref: '#/parameters/rowFilter.task_tags.tag', + }, + { + $ref: '#/parameters/select', + }, + { + $ref: '#/parameters/order', + }, + { + $ref: '#/parameters/range', + }, + { + $ref: '#/parameters/rangeUnit', + }, + { + $ref: '#/parameters/offset', + }, + { + $ref: '#/parameters/limit', + }, + { + $ref: '#/parameters/preferCount', + }, + ], + responses: { + '200': { + description: 'OK', + schema: { + items: { + $ref: '#/definitions/task_tags', + }, + type: 'array', + }, + }, + '206': { + description: 'Partial Content', + }, + }, + tags: ['task_tags'], + }, + post: { + parameters: [ + { + $ref: '#/parameters/body.task_tags', + }, + { + $ref: '#/parameters/select', + }, + { + $ref: '#/parameters/preferPost', + }, + ], + responses: { + '201': { + description: 'Created', + }, + }, + tags: ['task_tags'], + }, + delete: { + parameters: [ + { + $ref: '#/parameters/rowFilter.task_tags.task_id', + }, + { + $ref: '#/parameters/rowFilter.task_tags.tag', + }, + { + $ref: '#/parameters/preferReturn', + }, + ], + responses: { + '204': { + description: 'No Content', + }, + }, + tags: ['task_tags'], + }, + patch: { + parameters: [ + { + $ref: '#/parameters/rowFilter.task_tags.task_id', + }, + { + $ref: '#/parameters/rowFilter.task_tags.tag', + }, + { + $ref: '#/parameters/body.task_tags', + }, + { + $ref: '#/parameters/preferReturn', + }, + ], + responses: { + '204': { + description: 'No Content', + }, + }, + tags: ['task_tags'], + }, + }, + '/tasks': { + get: { + parameters: [ + { + $ref: '#/parameters/rowFilter.tasks.id', + }, + { + $ref: '#/parameters/rowFilter.tasks.owner_id', + }, + { + $ref: '#/parameters/rowFilter.tasks.title', + }, + { + $ref: '#/parameters/rowFilter.tasks.notes', + }, + { + $ref: '#/parameters/rowFilter.tasks.done', + }, + { + $ref: '#/parameters/rowFilter.tasks.created_at', + }, + { + $ref: '#/parameters/select', + }, + { + $ref: '#/parameters/order', + }, + { + $ref: '#/parameters/range', + }, + { + $ref: '#/parameters/rangeUnit', + }, + { + $ref: '#/parameters/offset', + }, + { + $ref: '#/parameters/limit', + }, + { + $ref: '#/parameters/preferCount', + }, + ], + responses: { + '200': { + description: 'OK', + schema: { + items: { + $ref: '#/definitions/tasks', + }, + type: 'array', + }, + }, + '206': { + description: 'Partial Content', + }, + }, + summary: "A user's to-do items.", + tags: ['tasks'], + }, + post: { + parameters: [ + { + $ref: '#/parameters/body.tasks', + }, + { + $ref: '#/parameters/select', + }, + { + $ref: '#/parameters/preferPost', + }, + ], + responses: { + '201': { + description: 'Created', + }, + }, + summary: "A user's to-do items.", + tags: ['tasks'], + }, + delete: { + parameters: [ + { + $ref: '#/parameters/rowFilter.tasks.id', + }, + { + $ref: '#/parameters/rowFilter.tasks.owner_id', + }, + { + $ref: '#/parameters/rowFilter.tasks.title', + }, + { + $ref: '#/parameters/rowFilter.tasks.notes', + }, + { + $ref: '#/parameters/rowFilter.tasks.done', + }, + { + $ref: '#/parameters/rowFilter.tasks.created_at', + }, + { + $ref: '#/parameters/preferReturn', + }, + ], + responses: { + '204': { + description: 'No Content', + }, + }, + summary: "A user's to-do items.", + tags: ['tasks'], + }, + patch: { + parameters: [ + { + $ref: '#/parameters/rowFilter.tasks.id', + }, + { + $ref: '#/parameters/rowFilter.tasks.owner_id', + }, + { + $ref: '#/parameters/rowFilter.tasks.title', + }, + { + $ref: '#/parameters/rowFilter.tasks.notes', + }, + { + $ref: '#/parameters/rowFilter.tasks.done', + }, + { + $ref: '#/parameters/rowFilter.tasks.created_at', + }, + { + $ref: '#/parameters/body.tasks', + }, + { + $ref: '#/parameters/preferReturn', + }, + ], + responses: { + '204': { + description: 'No Content', + }, + }, + summary: "A user's to-do items.", + tags: ['tasks'], + }, + }, + '/rpc/task_summary': { + get: { + parameters: [ + { + format: 'boolean', + in: 'query', + name: 'p_done', + required: false, + type: 'boolean', + }, + ], + produces: [ + 'application/json', + 'application/vnd.pgrst.object+json;nulls=stripped', + 'application/vnd.pgrst.object+json', + ], + responses: { + '200': { + description: 'OK', + }, + }, + summary: 'Tasks filtered by completion state, scoped to the caller.', + tags: ['(rpc) task_summary'], + }, + post: { + parameters: [ + { + in: 'body', + name: 'args', + required: true, + schema: { + description: + 'Tasks filtered by completion state, scoped to the caller.', + properties: { + p_done: { + format: 'boolean', + type: 'boolean', + }, + }, + type: 'object', + }, + }, + { + $ref: '#/parameters/preferParams', + }, + ], + produces: [ + 'application/json', + 'application/vnd.pgrst.object+json;nulls=stripped', + 'application/vnd.pgrst.object+json', + ], + responses: { + '200': { + description: 'OK', + }, + }, + summary: 'Tasks filtered by completion state, scoped to the caller.', + tags: ['(rpc) task_summary'], + }, + }, + '/rpc/list_tasks': { + get: { + produces: [ + 'application/json', + 'application/vnd.pgrst.object+json;nulls=stripped', + 'application/vnd.pgrst.object+json', + ], + responses: { + '200': { + description: 'OK', + }, + }, + tags: ['(rpc) list_tasks'], + }, + post: { + parameters: [ + { + in: 'body', + name: 'args', + required: true, + schema: { + type: 'object', + }, + }, + { + $ref: '#/parameters/preferParams', + }, + ], + produces: [ + 'application/json', + 'application/vnd.pgrst.object+json;nulls=stripped', + 'application/vnd.pgrst.object+json', + ], + responses: { + '200': { + description: 'OK', + }, + }, + tags: ['(rpc) list_tasks'], + }, + }, + '/rpc/complete_task': { + post: { + parameters: [ + { + in: 'body', + name: 'args', + required: true, + schema: { + properties: { + p_id: { + format: 'int64', + type: 'integer', + }, + }, + required: ['p_id'], + type: 'object', + }, + }, + { + $ref: '#/parameters/preferParams', + }, + ], + produces: [ + 'application/json', + 'application/vnd.pgrst.object+json;nulls=stripped', + 'application/vnd.pgrst.object+json', + ], + responses: { + '200': { + description: 'OK', + }, + }, + tags: ['(rpc) complete_task'], + }, + }, + }, + definitions: { + open_tasks: { + properties: { + id: { + description: 'Note:\nThis is a Primary Key.', + format: 'int64', + type: 'integer', + }, + title: { + format: 'text', + type: 'string', + }, + created_at: { + format: 'timestamp with time zone', + type: 'string', + }, + }, + type: 'object', + }, + audit_log: { + required: ['occurred_at', 'message'], + properties: { + occurred_at: { + default: 'now()', + format: 'timestamp with time zone', + type: 'string', + }, + message: { + format: 'text', + type: 'string', + }, + }, + type: 'object', + }, + notes: { + required: ['id', 'user_id', 'body', 'created_at'], + properties: { + id: { + default: 'gen_random_uuid()', + description: 'Note:\nThis is a Primary Key.', + format: 'uuid', + type: 'string', + }, + user_id: { + format: 'uuid', + type: 'string', + }, + body: { + format: 'text', + type: 'string', + }, + created_at: { + default: 'now()', + format: 'timestamp with time zone', + type: 'string', + }, + }, + type: 'object', + }, + task_tags: { + required: ['task_id', 'tag'], + properties: { + task_id: { + description: + "Note:\nThis is a Primary Key.\nThis is a Foreign Key to `tasks.id`.", + format: 'int64', + type: 'integer', + }, + tag: { + description: 'Note:\nThis is a Primary Key.', + format: 'text', + type: 'string', + }, + }, + type: 'object', + }, + tasks: { + description: "A user's to-do items.", + required: ['id', 'owner_id', 'title', 'done', 'created_at'], + properties: { + id: { + description: 'Note:\nThis is a Primary Key.', + format: 'int64', + type: 'integer', + }, + owner_id: { + default: 'auth.uid()', + format: 'uuid', + type: 'string', + }, + title: { + description: 'Short label shown in the list.', + format: 'text', + type: 'string', + }, + notes: { + format: 'text', + type: 'string', + }, + done: { + default: false, + format: 'boolean', + type: 'boolean', + }, + created_at: { + default: 'now()', + format: 'timestamp with time zone', + type: 'string', + }, + }, + type: 'object', + }, + }, + parameters: { + preferParams: { + name: 'Prefer', + description: 'Preference', + required: false, + in: 'header', + type: 'string', + }, + preferReturn: { + name: 'Prefer', + description: 'Preference', + required: false, + enum: ['return=representation', 'return=minimal', 'return=none'], + in: 'header', + type: 'string', + }, + preferCount: { + name: 'Prefer', + description: 'Preference', + required: false, + enum: ['count=none'], + in: 'header', + type: 'string', + }, + preferPost: { + name: 'Prefer', + description: 'Preference', + required: false, + enum: [ + 'return=representation', + 'return=minimal', + 'return=none', + 'resolution=ignore-duplicates', + 'resolution=merge-duplicates', + ], + in: 'header', + type: 'string', + }, + select: { + name: 'select', + description: 'Filtering Columns', + required: false, + in: 'query', + type: 'string', + }, + on_conflict: { + name: 'on_conflict', + description: 'On Conflict', + required: false, + in: 'query', + type: 'string', + }, + order: { + name: 'order', + description: 'Ordering', + required: false, + in: 'query', + type: 'string', + }, + range: { + name: 'Range', + description: 'Limiting and Pagination', + required: false, + in: 'header', + type: 'string', + }, + rangeUnit: { + name: 'Range-Unit', + description: 'Limiting and Pagination', + required: false, + default: 'items', + in: 'header', + type: 'string', + }, + offset: { + name: 'offset', + description: 'Limiting and Pagination', + required: false, + in: 'query', + type: 'string', + }, + limit: { + name: 'limit', + description: 'Limiting and Pagination', + required: false, + in: 'query', + type: 'string', + }, + 'body.open_tasks': { + name: 'open_tasks', + description: 'open_tasks', + required: false, + in: 'body', + schema: { + $ref: '#/definitions/open_tasks', + }, + }, + 'rowFilter.open_tasks.id': { + name: 'id', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.open_tasks.title': { + name: 'title', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.open_tasks.created_at': { + name: 'created_at', + required: false, + in: 'query', + type: 'string', + }, + 'body.audit_log': { + name: 'audit_log', + description: 'audit_log', + required: false, + in: 'body', + schema: { + $ref: '#/definitions/audit_log', + }, + }, + 'rowFilter.audit_log.occurred_at': { + name: 'occurred_at', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.audit_log.message': { + name: 'message', + required: false, + in: 'query', + type: 'string', + }, + 'body.notes': { + name: 'notes', + description: 'notes', + required: false, + in: 'body', + schema: { + $ref: '#/definitions/notes', + }, + }, + 'rowFilter.notes.id': { + name: 'id', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.notes.user_id': { + name: 'user_id', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.notes.body': { + name: 'body', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.notes.created_at': { + name: 'created_at', + required: false, + in: 'query', + type: 'string', + }, + 'body.task_tags': { + name: 'task_tags', + description: 'task_tags', + required: false, + in: 'body', + schema: { + $ref: '#/definitions/task_tags', + }, + }, + 'rowFilter.task_tags.task_id': { + name: 'task_id', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.task_tags.tag': { + name: 'tag', + required: false, + in: 'query', + type: 'string', + }, + 'body.tasks': { + name: 'tasks', + description: 'tasks', + required: false, + in: 'body', + schema: { + $ref: '#/definitions/tasks', + }, + }, + 'rowFilter.tasks.id': { + name: 'id', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.tasks.owner_id': { + name: 'owner_id', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.tasks.title': { + name: 'title', + description: 'Short label shown in the list.', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.tasks.notes': { + name: 'notes', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.tasks.done': { + name: 'done', + required: false, + in: 'query', + type: 'string', + }, + 'rowFilter.tasks.created_at': { + name: 'created_at', + required: false, + in: 'query', + type: 'string', + }, + }, + externalDocs: { + description: 'PostgREST Documentation', + url: 'https://postgrest.org/en/v16/references/api.html', + }, +} + +/** Returns a copy of `spec` without the given paths. */ +export function withoutPaths( + spec: PostgrestOpenApiSpec, + ...paths: string[] +): PostgrestOpenApiSpec { + return { + ...spec, + paths: Object.fromEntries( + Object.entries(spec.paths).filter(([path]) => !paths.includes(path)), + ), + } +} + +/** The capture without the colliding function: the default for tests. */ +export const probeSpec: PostgrestOpenApiSpec = withoutPaths( + probeSpecWithCollision, + '/rpc/list_tasks', +) diff --git a/src/mcp/generate.test.ts b/src/mcp/generate.test.ts new file mode 100644 index 0000000..0ca2f09 --- /dev/null +++ b/src/mcp/generate.test.ts @@ -0,0 +1,666 @@ +import { fromJsonSchema } from '@modelcontextprotocol/server' +import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { describe, expect, it } from 'vitest' + +import { + SpecFetchFailedError, + ToolGenerationError, + ToolNameCollisionError, +} from '../errors.js' +import { + probeSpec, + probeSpecWithCollision, + withoutPaths, +} from './__fixtures__/probe-spec.js' +import { + fetchSpec, + generateToolDefinitions, + generateTools, +} from './generate.js' +import type { GeneratedTool } from './types.js' + +const PROJECT_URL = 'https://project.supabase.co' +const PUBLISHABLE_KEY = 'sb_publishable_test' + +interface Recorded { + method: string + url: URL + headers: Headers + body: unknown +} + +interface Reply { + status?: number + body: unknown +} + +/** + * A real supabase-js client whose fetch records every request and answers from + * a queue, so handler tests assert the exact PostgREST request without a + * network. The Authorization header stands in for the caller's JWT. + */ +function recordingClient(replies: Reply[] = [{ body: [] }]) { + const calls: Recorded[] = [] + const queue = [...replies] + const fetchImpl: typeof fetch = async (input, init) => { + const href = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + calls.push({ + method: init?.method ?? 'GET', + url: new URL(href), + headers: new Headers(init?.headers), + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + }) + const reply = queue.shift() ?? { body: [] } + return new Response(JSON.stringify(reply.body), { + status: reply.status ?? 200, + headers: { 'content-type': 'application/json' }, + }) + } + const client = createClient(PROJECT_URL, PUBLISHABLE_KEY, { + global: { fetch: fetchImpl, headers: { Authorization: 'Bearer user-jwt' } }, + }) + return { client, calls } +} + +/** A client that must never be called: generation itself does no I/O. */ +const untouched = (): SupabaseClient => + createClient(PROJECT_URL, PUBLISHABLE_KEY, { + global: { + fetch: () => { + throw new Error('generation must not perform requests') + }, + }, + }) + +const READ_ONLY = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} + +const text = (data: unknown) => ({ + content: [{ type: 'text', text: JSON.stringify(data) }], +}) + +const properties = (tool: GeneratedTool) => + tool.inputSchema.properties as Record> + +describe('generateToolDefinitions - which tools exist', () => { + const tools = generateToolDefinitions(untouched(), probeSpec) + + it('generates one tool per operation, keyed by tool name', () => { + const relationTools = (relation: string) => [ + `list_${relation}`, + `get_${relation}`, + `create_${relation}`, + `update_${relation}`, + `delete_${relation}`, + ] + expect(Object.keys(tools).sort()).toEqual( + [ + ...relationTools('tasks'), + ...relationTools('task_tags'), + ...relationTools('open_tasks'), + ...relationTools('notes'), + 'list_audit_log', + 'create_audit_log', + 'task_summary', + 'complete_task', + ].sort(), + ) + for (const [key, tool] of Object.entries(tools)) { + expect(tool.name).toBe(key) + } + }) + + it('skips the root path and relations without a definition', () => { + const spec = { + ...probeSpec, + paths: { ...probeSpec.paths, '/ghost': { get: {} } }, + } + const names = Object.keys(generateToolDefinitions(untouched(), spec)) + expect(names).not.toContain('list_ghost') + expect(names.some((name) => name.endsWith('_'))).toBe(false) + }) + + it('does not generate get_, update_ or delete_ for a relation without a primary key', () => { + expect(tools.list_audit_log).toBeDefined() + expect(tools.create_audit_log).toBeDefined() + expect(tools.get_audit_log).toBeUndefined() + expect(tools.update_audit_log).toBeUndefined() + expect(tools.delete_audit_log).toBeUndefined() + }) + + it('requires every primary-key column for a composite key', () => { + for (const name of [ + 'get_task_tags', + 'update_task_tags', + 'delete_task_tags', + ]) { + expect(tools[name].inputSchema.required).toEqual(['task_id', 'tag']) + } + expect(Object.keys(properties(tools.get_task_tags))).toEqual([ + 'task_id', + 'tag', + ]) + expect(Object.keys(properties(tools.delete_task_tags))).toEqual([ + 'task_id', + 'tag', + ]) + // update_ takes every column; only the key is required. + expect(Object.keys(properties(tools.update_task_tags))).toEqual([ + 'task_id', + 'tag', + ]) + expect(Object.keys(properties(tools.update_tasks))).toEqual([ + 'id', + 'owner_id', + 'title', + 'notes', + 'done', + 'created_at', + ]) + expect(tools.update_tasks.inputSchema.required).toEqual(['id']) + }) + + it('excludes primary keys and columns with a default from create_ required', () => { + // required in the description is the NOT NULL list: id, owner_id, title, + // done, created_at. id is the identity key; owner_id, done and created_at + // carry defaults. + expect(tools.create_tasks.inputSchema.required).toEqual(['title']) + expect(Object.keys(properties(tools.create_tasks))).toHaveLength(6) + expect(tools.create_audit_log.inputSchema.required).toEqual(['message']) + // A composite key with nothing else leaves no required column at all. + expect(tools.create_task_tags.inputSchema.required).toBeUndefined() + }) + + it('gives list_ an equality filter per scalar column plus order, limit and offset', () => { + const list = properties(tools.list_tasks) + expect(Object.keys(list)).toEqual([ + 'id', + 'owner_id', + 'title', + 'notes', + 'done', + 'created_at', + 'order', + 'limit', + 'offset', + ]) + expect(list.limit).toMatchObject({ + type: 'integer', + minimum: 1, + maximum: 1000, + }) + expect(list.offset).toMatchObject({ type: 'integer', minimum: 0 }) + expect(list.order.description).toContain('created_at') + expect(tools.list_tasks.inputSchema.required).toBeUndefined() + expect(tools.list_tasks.inputSchema.additionalProperties).toBe(false) + }) +}) + +describe('generateToolDefinitions - descriptions and schemas', () => { + const tools = generateToolDefinitions(untouched(), probeSpec) + + it('takes descriptions from comments and falls back to the operation otherwise', () => { + expect(tools.list_tasks.description).toBe( + 'A user\'s to-do items. Lists rows of "tasks". Each column argument is an equality filter; combine with order, limit and offset.', + ) + expect(tools.get_tasks.description).toBe( + 'A user\'s to-do items. Fetches one row of "tasks" by primary key.', + ) + expect(tools.list_task_tags.description).toBe( + 'Lists rows of "task_tags". Each column argument is an equality filter; combine with order, limit and offset.', + ) + expect(properties(tools.create_tasks).title.description).toContain( + 'Short label shown in the list.', + ) + expect(tools.task_summary.description).toBe( + 'Tasks filtered by completion state, scoped to the caller.', + ) + expect(tools.complete_task.description).toBe( + 'Calls the database function "complete_task".', + ) + }) + + it('moves format and default into the description, never into schema keywords', () => { + const tasks = properties(tools.create_tasks) + expect(tasks.id).toEqual({ + type: 'integer', + description: 'Postgres type: int64.', + }) + expect(tasks.owner_id).toEqual({ + type: 'string', + description: 'Postgres type: uuid. Default: auth.uid().', + }) + expect(tasks.done).toEqual({ + type: 'boolean', + description: 'Postgres type: boolean. Default: false.', + }) + for (const tool of Object.values(tools)) { + for (const property of Object.values(properties(tool))) { + expect(property).not.toHaveProperty('format') + expect(property).not.toHaveProperty('default') + } + } + }) + + it('does not hard-code the set of format values', () => { + // The same bigint column reports `int64` on one PostgREST version and + // `bigint` on another. + const spec = { + ...probeSpec, + paths: { '/things': { get: {} } }, + definitions: { + things: { + properties: { amount: { type: 'integer', format: 'bigint' } }, + }, + }, + } + const { list_things } = generateToolDefinitions(untouched(), spec) + expect(properties(list_things).amount).toEqual({ + type: 'integer', + description: 'Postgres type: bigint.', + }) + }) + + it('drops the primary-key markup but keeps the foreign-key note', () => { + expect(properties(tools.get_task_tags).task_id.description).toBe( + 'This is a Foreign Key to `tasks.id`. Postgres type: int64.', + ) + expect(properties(tools.get_task_tags).tag.description).toBe( + 'Postgres type: text.', + ) + }) + + it('takes function arguments from the POST body schema', () => { + expect(tools.task_summary.inputSchema).toEqual({ + type: 'object', + properties: { + p_done: { type: 'boolean', description: 'Postgres type: boolean.' }, + }, + additionalProperties: false, + }) + expect(tools.complete_task.inputSchema).toEqual({ + type: 'object', + properties: { + p_id: { type: 'integer', description: 'Postgres type: int64.' }, + }, + required: ['p_id'], + additionalProperties: false, + }) + }) + + it('annotates each operation per the design table', () => { + expect(tools.list_tasks.annotations).toEqual(READ_ONLY) + expect(tools.get_tasks.annotations).toEqual(READ_ONLY) + expect(tools.create_tasks.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }) + expect(tools.update_tasks.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }) + expect(tools.delete_tasks.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }) + // STABLE → PostgREST exposes GET → read-only. + expect(tools.task_summary.annotations).toEqual(READ_ONLY) + // VOLATILE → POST only → conservative, and the only openWorldHint: true. + expect(tools.complete_task.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }) + const openWorld = Object.values(tools).filter( + (tool) => tool.annotations.openWorldHint, + ) + expect(openWorld.map((tool) => tool.name)).toEqual(['complete_task']) + }) + + it('records provenance in _meta on every tool', () => { + expect(tools.list_tasks._meta).toEqual({ + kind: 'relation', + name: 'tasks', + method: 'GET', + }) + expect(tools.create_tasks._meta).toEqual({ + kind: 'relation', + name: 'tasks', + method: 'POST', + }) + expect(tools.update_tasks._meta).toEqual({ + kind: 'relation', + name: 'tasks', + method: 'PATCH', + }) + expect(tools.delete_tasks._meta).toEqual({ + kind: 'relation', + name: 'tasks', + method: 'DELETE', + }) + expect(tools.task_summary._meta).toEqual({ + kind: 'function', + name: 'task_summary', + method: 'GET', + }) + expect(tools.complete_task._meta).toEqual({ + kind: 'function', + name: 'complete_task', + method: 'POST', + }) + for (const tool of Object.values(tools)) { + expect(['relation', 'function']).toContain(tool._meta.kind) + } + }) + + it('fails with TOOL_NAME_COLLISION instead of overwriting a tool', () => { + let caught: unknown + try { + generateToolDefinitions(untouched(), probeSpecWithCollision) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(ToolGenerationError) + const error = caught as ToolGenerationError + expect(error.code).toBe(ToolNameCollisionError) + expect(error.status).toBe(500) + expect(error.message).toContain('"list_tasks"') + expect(error.details).toEqual({ + name: 'list_tasks', + operations: ['relation "tasks" (GET)', 'function "list_tasks" (GET)'], + }) + expect(error.hint).toContain('Rename the database function') + }) + + it('emits input schemas the MCP SDK accepts as they are', async () => { + for (const tool of Object.values(tools)) { + const schema = fromJsonSchema(tool.inputSchema as never) + expect( + schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }), + ).toEqual(tool.inputSchema) + } + const list = fromJsonSchema(tools.list_tasks.inputSchema as never) + expect(await list['~standard'].validate({ limit: 'ten' })).toHaveProperty( + 'issues', + ) + expect(await list['~standard'].validate({ done: true, limit: 5 })).toEqual({ + value: { done: true, limit: 5 }, + }) + expect( + await list['~standard'].validate({ unknown_column: 1 }), + ).toHaveProperty('issues') + }) +}) + +describe('generateToolDefinitions - execution through the caller-scoped client', () => { + const rows = [{ id: 1, title: 'Buy milk' }] + + it('list_ selects everything with the default page and forwards the caller token', async () => { + const { client, calls } = recordingClient([{ body: rows }]) + const tools = generateToolDefinitions(client, probeSpec) + + const result = await tools.list_tasks.handler({}) + + expect(result).toEqual(text(rows)) + expect(calls).toHaveLength(1) + const [call] = calls + expect(call.method).toBe('GET') + // The client's URL, never the `host` from the description (0.0.0.0:3000). + expect(call.url.origin).toBe(PROJECT_URL) + expect(call.url.pathname).toBe('/rest/v1/tasks') + expect(Object.fromEntries(call.url.searchParams)).toEqual({ + select: '*', + offset: '0', + limit: '100', + }) + expect(call.headers.get('authorization')).toBe('Bearer user-jwt') + expect(call.headers.get('apikey')).toBe(PUBLISHABLE_KEY) + }) + + it('list_ turns arguments into equality filters, order and a range', async () => { + const { client, calls } = recordingClient() + const tools = generateToolDefinitions(client, probeSpec) + + await tools.list_tasks.handler({ + done: false, + title: 'x', + order: 'created_at.desc', + limit: 10, + offset: 20, + }) + + expect(Object.fromEntries(calls[0].url.searchParams)).toEqual({ + select: '*', + done: 'eq.false', + title: 'eq.x', + order: 'created_at.desc', + offset: '20', + limit: '10', + }) + }) + + it('list_ orders ascending by default and rejects unknown columns before any request', async () => { + const { client, calls } = recordingClient() + const tools = generateToolDefinitions(client, probeSpec) + + await tools.list_tasks.handler({ order: 'title' }) + expect(calls[0].url.searchParams.get('order')).toBe('title.asc') + + await expect( + tools.list_tasks.handler({ order: 'nope.desc' }), + ).rejects.toThrow(/Cannot order by "nope"/) + expect(calls).toHaveLength(1) + }) + + it('get_ matches on the primary key and reports a missing row', async () => { + const { client, calls } = recordingClient([{ body: rows }, { body: [] }]) + const tools = generateToolDefinitions(client, probeSpec) + + expect(await tools.get_tasks.handler({ id: 1 })).toEqual(text(rows[0])) + expect(calls[0].url.searchParams.get('id')).toBe('eq.1') + + await expect(tools.get_tasks.handler({ id: 2 })).rejects.toThrow( + 'No row of "tasks" matches {"id":2}.', + ) + }) + + it('get_ on a composite key sends every key column', async () => { + const { client, calls } = recordingClient([ + { body: [{ task_id: 1, tag: 'x' }] }, + ]) + const tools = generateToolDefinitions(client, probeSpec) + + await tools.get_task_tags.handler({ task_id: 1, tag: 'x' }) + + expect(Object.fromEntries(calls[0].url.searchParams)).toEqual({ + select: '*', + task_id: 'eq.1', + tag: 'eq.x', + }) + }) + + it('create_ posts the row and returns the representation', async () => { + const { client, calls } = recordingClient([{ status: 201, body: rows[0] }]) + const tools = generateToolDefinitions(client, probeSpec) + + expect(await tools.create_tasks.handler({ title: 'Buy milk' })).toEqual( + text(rows[0]), + ) + const [call] = calls + expect(call.method).toBe('POST') + expect(call.url.pathname).toBe('/rest/v1/tasks') + expect(call.body).toEqual({ title: 'Buy milk' }) + expect(call.url.searchParams.get('select')).toBe('*') + expect(call.headers.get('prefer')).toContain('return=representation') + }) + + it('update_ patches only the non-key columns and refuses an empty change', async () => { + const { client, calls } = recordingClient([{ body: { id: 1, done: true } }]) + const tools = generateToolDefinitions(client, probeSpec) + + expect(await tools.update_tasks.handler({ id: 1, done: true })).toEqual( + text({ id: 1, done: true }), + ) + const [call] = calls + expect(call.method).toBe('PATCH') + expect(call.body).toEqual({ done: true }) + expect(call.url.searchParams.get('id')).toBe('eq.1') + + await expect(tools.update_tasks.handler({ id: 1 })).rejects.toThrow( + /Nothing to update in "tasks"/, + ) + expect(calls).toHaveLength(1) + }) + + it('delete_ deletes by primary key and returns the deleted row', async () => { + const { client, calls } = recordingClient([{ body: rows[0] }]) + const tools = generateToolDefinitions(client, probeSpec) + + expect(await tools.delete_tasks.handler({ id: 1 })).toEqual(text(rows[0])) + const [call] = calls + expect(call.method).toBe('DELETE') + expect(call.url.searchParams.get('id')).toBe('eq.1') + expect(call.url.searchParams.get('select')).toBe('*') + }) + + it('calls GET-capable functions with GET and the rest with POST', async () => { + const { client, calls } = recordingClient([ + { body: rows }, + { body: rows[0] }, + ]) + const tools = generateToolDefinitions(client, probeSpec) + + await tools.task_summary.handler({ p_done: true }) + expect(calls[0].method).toBe('GET') + expect(calls[0].url.pathname).toBe('/rest/v1/rpc/task_summary') + expect(calls[0].url.searchParams.get('p_done')).toBe('true') + + await tools.complete_task.handler({ p_id: 1 }) + expect(calls[1].method).toBe('POST') + expect(calls[1].url.pathname).toBe('/rest/v1/rpc/complete_task') + expect(calls[1].body).toEqual({ p_id: 1 }) + }) + + it('throws what PostgREST said so the SDK reports a tool error', async () => { + const { client } = recordingClient([ + { + status: 403, + body: { + code: '42501', + message: 'permission denied for table tasks', + details: null, + hint: null, + }, + }, + ]) + const tools = generateToolDefinitions(client, probeSpec) + + await expect(tools.create_tasks.handler({ title: 'x' })).rejects.toThrow( + 'permission denied for table tasks (42501)', + ) + }) +}) + +describe('fetchSpec', () => { + const clientReturning = (response: unknown): SupabaseClient => + ({ getOpenApiSpec: async () => response }) as unknown as SupabaseClient + + const failure = async ( + promise: Promise, + ): Promise => { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(ToolGenerationError) + return error as ToolGenerationError + } + throw new Error('expected a rejection') + } + + it('delegates to supabase-js and returns the document', async () => { + const client = clientReturning({ + data: probeSpec, + error: null, + status: 200, + }) + expect(await fetchSpec(client)).toBe(probeSpec) + // generateTools is the two steps composed. + const tools = await generateTools(client) + expect(Object.keys(tools)).toContain('list_tasks') + }) + + it.each([ + [404, /OpenAPI output is disabled/], + [406, /OpenAPI output is disabled/], + [401, /rejected the credentials/], + [403, /rejected the credentials/], + [0, /never reached PostgREST/], + [500, /healthy Data API/], + ])( + 'maps a PostgREST failure with status %i to SPEC_FETCH_FAILED', + async (status, hint) => { + const postgrestError = { + message: 'nope', + details: '', + hint: '', + code: 'X', + } + const error = await failure( + fetchSpec( + clientReturning({ data: null, error: postgrestError, status }), + ), + ) + expect(error.code).toBe(SpecFetchFailedError) + expect(error.status).toBe(500) + expect(error.message).toContain(`(HTTP ${status}): nope`) + expect(error.hint).toMatch(hint) + expect(error.details).toEqual({ status }) + expect(error.cause).toBe(postgrestError) + }, + ) + + it('rejects a body that is not a Swagger 2.0 document with definitions', async () => { + const error = await failure( + fetchSpec( + clientReturning({ + data: { openapi: '3.0.0', paths: {} }, + error: null, + status: 200, + }), + ), + ) + expect(error.code).toBe(SpecFetchFailedError) + expect(error.message).toContain('not a Swagger 2.0 document') + expect(error.details).toEqual({ status: 200 }) + }) + + it('tells older supabase-js clients to upgrade', async () => { + const error = await failure(fetchSpec({} as unknown as SupabaseClient)) + expect(error.code).toBe(SpecFetchFailedError) + expect(error.hint).toContain('2.115.0') + }) + + it('ignores the paths a caller removes from the document', async () => { + const spec = withoutPaths(probeSpec, '/tasks', '/rpc/task_summary') + const tools = generateToolDefinitions(untouched(), spec) + expect(tools.list_tasks).toBeUndefined() + expect(tools.task_summary).toBeUndefined() + expect(tools.list_notes).toBeDefined() + }) +}) diff --git a/src/mcp/generate.ts b/src/mcp/generate.ts new file mode 100644 index 0000000..42117a3 --- /dev/null +++ b/src/mcp/generate.ts @@ -0,0 +1,597 @@ +import type { + PostgrestError, + PostgrestOpenApiSpec, + SupabaseClient, +} from '@supabase/supabase-js' +import type { + CallToolResult, + ToolAnnotations, +} from '@modelcontextprotocol/server' + +import { + Errors, + SpecFetchFailedError, + ToolNameCollisionError, +} from '../errors.js' +import type { GeneratedTool, ToolMeta } from './types.js' + +// The parts of PostgREST's Swagger 2.0 document read here. Shapes were read off +// a running PostgREST (see src/mcp/__fixtures__/probe-spec.ts), not inferred. +interface SwaggerProperty { + type?: string + format?: string + default?: unknown + description?: string + enum?: unknown[] + items?: { type?: string } +} + +interface SwaggerDefinition { + description?: string + required?: string[] + properties?: Record +} + +interface SwaggerParameter { + in?: string + schema?: { + required?: string[] + properties?: Record + } +} + +interface SwaggerOperation { + summary?: string + parameters?: Array +} + +type JsonSchema = Record + +/** + * Table and function names are only known at run time, so the query builders + * run on the untyped client. Callers keep their `SupabaseClient`. + */ +type Client = SupabaseClient + +/** PostgREST marks primary-key columns with this literal in the description. */ +const PK_MARKER = '' + +/** `list_` arguments that are not column filters. */ +const RESERVED = new Set(['order', 'limit', 'offset']) + +const READ_ONLY: ToolAnnotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} + +/** + * Generates one MCP tool per operation PostgREST exposes to the client's role. + * + * The description is fetched through `supabase.getOpenApiSpec()`, so it + * carries the caller's token and the client's schema: tools exist only for the + * tables, views and functions the caller's role holds privileges on. When a + * tool runs, the same client executes it and Row Level Security applies. + * + * Returned as a record keyed by tool name. `tool.name` is authoritative; the + * key is an index for ergonomics, so overriding entries cannot rename a tool. + * + * @example + * ```ts + * const tools = await generateTools(supabase) + * // tools.list_notes, tools.create_notes, tools.submit_expense, … + * delete tools.delete_notes + * registerTools(server, tools) + * ``` + * + * @throws {ToolGenerationError} `SPEC_FETCH_FAILED` when the description cannot + * be read, `TOOL_NAME_COLLISION` when two operations produce the same name. + * @category MCP + */ +export async function generateTools( + supabase: SupabaseClient, +): Promise> { + return generateToolDefinitions(supabase, await fetchSpec(supabase)) +} + +/** + * Fetches the Swagger 2.0 description PostgREST publishes for the client's + * schema, scoped to the caller. One call to supabase-js; nothing here builds + * a request or holds a credential. + * + * @internal + */ +export async function fetchSpec( + supabase: SupabaseClient, +): Promise { + if (typeof supabase.getOpenApiSpec !== 'function') { + throw Errors[SpecFetchFailedError]({ reason: 'unsupported-client' }) + } + const { data, error, status } = await supabase.getOpenApiSpec() + if (error) { + throw Errors[SpecFetchFailedError]({ + reason: 'request', + status, + message: error.message, + cause: error, + }) + } + if (!isSwaggerDocument(data)) { + throw Errors[SpecFetchFailedError]({ reason: 'malformed', status }) + } + return data +} + +function isSwaggerDocument(value: unknown): value is PostgrestOpenApiSpec { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { swagger?: unknown }).swagger === 'string' && + typeof (value as { paths?: unknown }).paths === 'object' && + typeof (value as { definitions?: unknown }).definitions === 'object' + ) +} + +/** + * Pure: a description in, tools out. Handlers close over `supabase` but nothing + * is fetched here. + * + * @internal + */ +export function generateToolDefinitions( + supabase: SupabaseClient, + spec: PostgrestOpenApiSpec, +): Record { + const client = supabase as unknown as Client + const tools: Record = {} + + const add = (tool: GeneratedTool): void => { + const existing = tools[tool.name] + if (existing) { + throw Errors[ToolNameCollisionError]({ + name: tool.name, + operations: [describeMeta(existing._meta), describeMeta(tool._meta)], + }) + } + tools[tool.name] = tool + } + + for (const relation of relations(spec)) { + for (const tool of relationTools(client, relation)) add(tool) + } + for (const fn of databaseFunctions(spec)) add(functionTool(client, fn)) + + return tools +} + +function describeMeta(meta: ToolMeta): string { + return `${meta.kind} "${meta.name}" (${meta.method})` +} + +// --------------------------------------------------------------------------- +// Relations: tables and views +// --------------------------------------------------------------------------- + +interface Relation { + name: string + definition: SwaggerDefinition + verbs: Set +} + +function relations(spec: PostgrestOpenApiSpec): Relation[] { + const found: Relation[] = [] + for (const [path, operations] of Object.entries(spec.paths)) { + if (path === '/' || path.startsWith('/rpc/')) continue + const name = path.slice(1) + const definition = spec.definitions?.[name] as SwaggerDefinition | undefined + if (!definition) continue + found.push({ name, definition, verbs: new Set(Object.keys(operations)) }) + } + return found +} + +function relationTools( + client: Client, + { name, definition, verbs }: Relation, +): GeneratedTool[] { + const properties = definition.properties ?? {} + const columns = Object.keys(properties) + const primaryKey = columns.filter((column) => + properties[column].description?.includes(PK_MARKER), + ) + const comment = cleanDescription(definition.description) + const describe = (sentence: string): string => + [comment, sentence].filter(Boolean).join(' ') + const columnSchemas = (names: string[]): Record => + Object.fromEntries( + names.map((column) => [column, columnSchema(properties[column])]), + ) + const notFound = (key: Record): Error => + new Error(`No row of "${name}" matches ${JSON.stringify(key)}.`) + + const tools: GeneratedTool[] = [] + + if (verbs.has('get')) { + const filterable = columns.filter( + (column) => isScalar(properties[column]) && !RESERVED.has(column), + ) + tools.push({ + name: `list_${name}`, + description: describe( + `Lists rows of "${name}". Each column argument is an equality filter; combine with order, limit and offset.`, + ), + inputSchema: objectSchema({ + ...columnSchemas(filterable), + order: { + type: 'string', + description: `Column to sort by, optionally followed by ".asc" or ".desc" (for example "created_at.desc"). Columns: ${columns.join(', ')}.`, + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 1000, + description: 'Maximum number of rows to return. Default 100.', + }, + offset: { + type: 'integer', + minimum: 0, + description: 'Number of rows to skip. Default 0.', + }, + }), + annotations: READ_ONLY, + _meta: { kind: 'relation', name, method: 'GET' }, + handler: async (args) => { + const { order, limit, offset, ...filters } = args + let query = client.from(name).select('*') + for (const [column, value] of Object.entries(filters)) { + if (value === undefined || !filterable.includes(column)) continue + query = query.eq(column, value) + } + if (typeof order === 'string' && order !== '') { + const sort = parseOrder(order, columns) + query = query.order(sort.column, { ascending: sort.ascending }) + } + const from = typeof offset === 'number' ? offset : 0 + const size = typeof limit === 'number' ? limit : 100 + return toResult(await query.range(from, from + size - 1)) + }, + }) + } + + if (verbs.has('get') && primaryKey.length > 0) { + tools.push({ + name: `get_${name}`, + description: describe(`Fetches one row of "${name}" by primary key.`), + inputSchema: objectSchema(columnSchemas(primaryKey), primaryKey), + annotations: READ_ONLY, + _meta: { kind: 'relation', name, method: 'GET' }, + handler: async (args) => { + const key = pick(args, primaryKey) + const { data, error } = await client + .from(name) + .select('*') + .match(key) + .maybeSingle() + if (error) throw postgrestError(error) + if (data === null) throw notFound(key) + return textResult(data) + }, + }) + } + + if (verbs.has('post')) { + // `required` in the description is the NOT NULL list. Columns with a + // database default need no value, and an identity primary key cannot take + // one at all — Postgres rejects it without OVERRIDING SYSTEM VALUE. + const required = (definition.required ?? []).filter( + (column) => + column in properties && + properties[column].default === undefined && + !primaryKey.includes(column), + ) + tools.push({ + name: `create_${name}`, + description: describe(`Inserts one row into "${name}" and returns it.`), + inputSchema: objectSchema(columnSchemas(columns), required), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + _meta: { kind: 'relation', name, method: 'POST' }, + handler: async (args) => + toResult(await client.from(name).insert(args).select('*').single()), + }) + } + + if (verbs.has('patch') && primaryKey.length > 0) { + tools.push({ + name: `update_${name}`, + description: describe( + `Updates one row of "${name}" by primary key and returns it. Only the columns given change.`, + ), + inputSchema: objectSchema(columnSchemas(columns), primaryKey), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + _meta: { kind: 'relation', name, method: 'PATCH' }, + handler: async (args) => { + const key = pick(args, primaryKey) + const changes = Object.fromEntries( + Object.entries(args).filter( + ([column, value]) => + !primaryKey.includes(column) && value !== undefined, + ), + ) + if (Object.keys(changes).length === 0) { + throw new Error( + `Nothing to update in "${name}": pass at least one column besides the primary key.`, + ) + } + const { data, error } = await client + .from(name) + .update(changes) + .match(key) + .select('*') + .maybeSingle() + if (error) throw postgrestError(error) + if (data === null) throw notFound(key) + return textResult(data) + }, + }) + } + + if (verbs.has('delete') && primaryKey.length > 0) { + tools.push({ + name: `delete_${name}`, + description: describe( + `Deletes one row of "${name}" by primary key and returns it.`, + ), + inputSchema: objectSchema(columnSchemas(primaryKey), primaryKey), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + _meta: { kind: 'relation', name, method: 'DELETE' }, + handler: async (args) => { + const key = pick(args, primaryKey) + const { data, error } = await client + .from(name) + .delete() + .match(key) + .select('*') + .maybeSingle() + if (error) throw postgrestError(error) + if (data === null) throw notFound(key) + return textResult(data) + }, + }) + } + + return tools +} + +function parseOrder( + order: string, + columns: string[], +): { column: string; ascending: boolean } { + const match = /^(.+?)(?:\.(asc|desc))?$/.exec(order) + const column = match?.[1] ?? order + if (!columns.includes(column)) { + throw new Error( + `Cannot order by "${column}": not a column. Columns: ${columns.join(', ')}.`, + ) + } + return { column, ascending: match?.[2] !== 'desc' } +} + +// --------------------------------------------------------------------------- +// Database functions: /rpc/ +// --------------------------------------------------------------------------- + +interface DatabaseFunction { + name: string + /** PostgREST exposes GET only for IMMUTABLE and STABLE functions. */ + hasGet: boolean + summary?: string + args: { required?: string[]; properties?: Record } +} + +function databaseFunctions(spec: PostgrestOpenApiSpec): DatabaseFunction[] { + const found: DatabaseFunction[] = [] + for (const [path, operations] of Object.entries(spec.paths)) { + if (!path.startsWith('/rpc/')) continue + const post = operations.post as SwaggerOperation | undefined + if (!post) continue + const body = (post.parameters ?? []) + .map((parameter) => resolveParameter(spec, parameter)) + .find((parameter) => parameter?.in === 'body') + found.push({ + name: path.slice('/rpc/'.length), + hasGet: 'get' in operations, + summary: post.summary, + args: body?.schema ?? {}, + }) + } + return found +} + +/** Resolves a `#/parameters/` reference one level; inline values pass through. */ +function resolveParameter( + spec: PostgrestOpenApiSpec, + parameter: SwaggerParameter | { $ref: string }, +): SwaggerParameter | undefined { + if (!('$ref' in parameter)) return parameter + const match = /^#\/parameters\/(.+)$/.exec(parameter.$ref) + if (!match) return undefined + const key = match[1].replace(/~1/g, '/').replace(/~0/g, '~') + return spec.parameters?.[key] as SwaggerParameter | undefined +} + +function functionTool(client: Client, fn: DatabaseFunction): GeneratedTool { + const properties = fn.args.properties ?? {} + return { + name: fn.name, + description: + cleanDescription(fn.summary) ?? + `Calls the database function "${fn.name}".`, + inputSchema: objectSchema( + Object.fromEntries( + Object.entries(properties).map(([arg, property]) => [ + arg, + columnSchema(property), + ]), + ), + fn.args.required, + ), + // A VOLATILE function may do anything, including reaching outside the + // project through an extension such as pg_net — hence openWorldHint. + annotations: fn.hasGet + ? READ_ONLY + : { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + _meta: { + kind: 'function', + name: fn.name, + method: fn.hasGet ? 'GET' : 'POST', + }, + handler: async (args) => + toResult(await client.rpc(fn.name, args, { get: fn.hasGet })), + } +} + +// --------------------------------------------------------------------------- +// Swagger → JSON Schema +// --------------------------------------------------------------------------- + +const JSON_SCHEMA_TYPES = new Set([ + 'string', + 'integer', + 'number', + 'boolean', + 'array', + 'object', +]) + +function isScalar(property: SwaggerProperty): boolean { + return ( + property.type === 'string' || + property.type === 'integer' || + property.type === 'number' || + property.type === 'boolean' + ) +} + +/** + * The Swagger property as JSON Schema. `type` and `enum` carry over; `format` + * (which varies by PostgREST version) and the SQL `default` go into the + * description for the model's benefit, never as validation keywords. + */ +function columnSchema(property: SwaggerProperty): JsonSchema { + const schema: JsonSchema = {} + if (property.type && JSON_SCHEMA_TYPES.has(property.type)) { + schema.type = property.type + } + if ( + property.type === 'array' && + property.items?.type && + JSON_SCHEMA_TYPES.has(property.items.type) + ) { + schema.items = { type: property.items.type } + } + if (property.enum) schema.enum = property.enum + const description = [ + cleanDescription(property.description), + property.format ? `Postgres type: ${property.format}.` : undefined, + property.default !== undefined + ? `Default: ${String(property.default)}.` + : undefined, + ] + .filter(Boolean) + .join(' ') + if (description) schema.description = description + return schema +} + +function objectSchema( + properties: Record, + required: string[] = [], +): JsonSchema { + return { + type: 'object', + properties, + ...(required.length > 0 ? { required } : {}), + additionalProperties: false, + } +} + +/** + * Strips PostgREST's key markup from a comment. The primary key is expressed + * by the tool schemas instead; the foreign-key sentence is kept because it + * tells the model what the column points at. + */ +function cleanDescription(text: string | undefined): string | undefined { + if (!text) return undefined + const cleaned = text + .replace(//g, '') + .replace(/]*\/>/g, '') + .split('\n') + .map((line) => line.trim()) + .filter( + (line) => + line !== '' && + line !== 'Note:' && + !line.startsWith('This is a Primary Key'), + ) + .join(' ') + .trim() + return cleaned === '' ? undefined : cleaned +} + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +function pick( + args: Record, + keys: string[], +): Record { + return Object.fromEntries(keys.map((key) => [key, args[key]])) +} + +function textResult(data: unknown): CallToolResult { + return { content: [{ type: 'text', text: JSON.stringify(data) }] } +} + +function toResult(response: { + data: unknown + error: PostgrestError | null +}): CallToolResult { + if (response.error) throw postgrestError(response.error) + return textResult(response.data) +} + +/** + * A thrown error becomes a tool error in the MCP SDK. The message carries + * everything PostgREST said so the model can act on it. + */ +function postgrestError(error: PostgrestError): Error { + const parts = [ + error.message, + error.details, + error.hint ? `Hint: ${error.hint}` : '', + error.code ? `(${error.code})` : '', + ].filter(Boolean) + return new Error(parts.join(' '), { cause: error }) +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000..ff9111a --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1,42 @@ +/** + * **Alpha.** MCP tools generated at run time from a project's PostgREST schema. + * + * `generateTools` reads the OpenAPI description PostgREST publishes for the + * caller's role — through the caller-scoped `ctx.supabase` client — and builds + * one tool per operation: `list_`, `get_`, `create_`, `update_` and `delete_` + * for every table and view the role can reach, and one tool per database + * function. Descriptions come from `COMMENT ON`; Row Level Security applies + * when a tool runs. `registerTools` hands them to the official MCP SDK. + * + * ```ts + * import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' + * import { withOAuthProtectedResource, withSupabase } from '@supabase/server' + * import { generateTools, registerTools } from '@supabase/server/mcp' + * + * Deno.serve( + * withOAuthProtectedResource( + * withSupabase({ auth: 'user' }, async (req, { supabase }) => { + * const handler = createMcpHandler(async () => { + * const server = new McpServer({ name: 'notes-mcp', version: '0.1.0' }) + * registerTools(server, await generateTools(supabase)) + * return server + * }) + * return handler.fetch(req) + * }), + * ), + * ) + * ``` + * + * Requires `@modelcontextprotocol/server` 2.x (optional peer dependency) and + * `@supabase/supabase-js` 2.115.0 or newer. Generated tool names, input + * schemas and annotations may change in a minor release while this surface is + * alpha. + * + * @alpha + * @module + * @packageDocumentation + */ + +export { generateTools } from './generate.js' +export { registerTools } from './register.js' +export type { GeneratedTool, ToolMeta } from './types.js' diff --git a/src/mcp/register.test.ts b/src/mcp/register.test.ts new file mode 100644 index 0000000..4bc86c2 --- /dev/null +++ b/src/mcp/register.test.ts @@ -0,0 +1,123 @@ +import { + McpServer, + type StandardSchemaWithJSON, + type ToolAnnotations, +} from '@modelcontextprotocol/server' +import { createClient } from '@supabase/supabase-js' +import { describe, expect, it, vi } from 'vitest' + +import { probeSpec } from './__fixtures__/probe-spec.js' +import { generateToolDefinitions } from './generate.js' +import { registerTools } from './register.js' +import type { GeneratedTool } from './types.js' + +const client = createClient( + 'https://project.supabase.co', + 'sb_publishable_test', +) +const tools = generateToolDefinitions(client, probeSpec) + +/** What `registerTools` hands to `server.registerTool`, as the SDK sees it. */ +interface RegisteredConfig { + description?: string + annotations?: ToolAnnotations + inputSchema?: StandardSchemaWithJSON +} +type Registration = [ + name: string, + config: RegisteredConfig, + callback: (args: unknown, ctx: unknown) => unknown, +] + +/** Records registerTool calls without running an MCP transport. */ +function recordingServer() { + const registerTool = vi.fn() + return { + server: { registerTool } as unknown as Pick, + calls: registerTool.mock.calls as unknown as Registration[], + } +} + +describe('registerTools - what reaches the SDK', () => { + it('registers every tool under its own name', () => { + const { server, calls } = recordingServer() + registerTools(server, tools) + expect(calls.map(([name]) => name).sort()).toEqual( + Object.keys(tools).sort(), + ) + }) + + it('forwards description and annotations, strips _meta, and wraps the schema', () => { + const { server, calls } = recordingServer() + registerTools(server, { list_tasks: tools.list_tasks }) + + const [, config] = calls[0] + expect(config.description).toBe(tools.list_tasks.description) + expect(config.annotations).toEqual(tools.list_tasks.annotations) + expect(config).not.toHaveProperty('_meta') + expect(config).not.toHaveProperty('handler') + expect(config).not.toHaveProperty('name') + // The SDK's fromJsonSchema wrapper advertises the same JSON Schema. + const schema = config.inputSchema! + expect( + schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }), + ).toEqual(tools.list_tasks.inputSchema) + }) + + it('validates arguments through the SDK before the handler runs', async () => { + const { server, calls } = recordingServer() + registerTools(server, { list_tasks: tools.list_tasks }) + + const schema = calls[0][1].inputSchema! + expect(await schema['~standard'].validate({ limit: 'ten' })).toHaveProperty( + 'issues', + ) + expect(await schema['~standard'].validate({ done: true })).toEqual({ + value: { done: true }, + }) + }) + + it('hands arguments to the generated handler', async () => { + const { server, calls } = recordingServer() + const handler = vi.fn(async (args: Record) => ({ + content: [{ type: 'text' as const, text: JSON.stringify(args) }], + })) + const tool: GeneratedTool = { ...tools.get_tasks, handler } + registerTools(server, { get_tasks: tool }) + + const [, , callback] = calls[0] + const result = await callback({ id: 7 }, {}) + + expect(handler).toHaveBeenCalledWith({ id: 7 }) + expect(result).toEqual({ content: [{ type: 'text', text: '{"id":7}' }] }) + }) + + it('registers whatever subset it is given, so filtering is plain JavaScript', () => { + const { server, calls } = recordingServer() + const functionTools = Object.fromEntries( + Object.entries(tools).filter( + ([, tool]) => tool._meta.kind === 'function', + ), + ) + registerTools(server, functionTools) + expect(calls.map(([name]) => name).sort()).toEqual([ + 'complete_task', + 'task_summary', + ]) + }) +}) + +describe('registerTools - against a real McpServer', () => { + it('registers the whole generated set', () => { + const server = new McpServer({ name: 'test', version: '0.0.0' }) + expect(() => registerTools(server, tools)).not.toThrow() + }) + + it("surfaces the SDK's own error for a name that is already registered", () => { + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerTools(server, { list_tasks: tools.list_tasks }) + expect(() => + registerTools(server, { list_tasks: tools.list_tasks }), + ).toThrow(/already registered/) + }) +}) diff --git a/src/mcp/register.ts b/src/mcp/register.ts new file mode 100644 index 0000000..455213d --- /dev/null +++ b/src/mcp/register.ts @@ -0,0 +1,44 @@ +import { + fromJsonSchema, + type JsonSchemaType, + type McpServer, +} from '@modelcontextprotocol/server' + +import type { GeneratedTool } from './types.js' + +/** + * Registers generated tools on an `McpServer` from `@modelcontextprotocol/server`. + * + * Each tool's JSON Schema is handed to the SDK's own `fromJsonSchema()`, so the + * SDK validates arguments and advertises the schema in `tools/list`. `_meta` is + * not forwarded. A name that is already registered — a hand-written tool with + * the same name — surfaces as the SDK's own error; replace or remove the + * generated entry first. + * + * @example + * ```ts + * const tools = await generateTools(supabase) + * delete tools.delete_notes + * registerTools(server, tools) + * ``` + * + * @category MCP + */ +export function registerTools( + server: Pick, + tools: Record, +): void { + for (const tool of Object.values(tools)) { + server.registerTool( + tool.name, + { + description: tool.description, + annotations: tool.annotations, + inputSchema: fromJsonSchema>( + tool.inputSchema as JsonSchemaType, + ), + }, + (args) => tool.handler(args), + ) + } +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts new file mode 100644 index 0000000..a77517b --- /dev/null +++ b/src/mcp/types.ts @@ -0,0 +1,57 @@ +import type { + CallToolResult, + ToolAnnotations, +} from '@modelcontextprotocol/server' + +/** + * What a generated tool was derived from. Read it to filter or group tools + * before registration — {@link registerTools} strips it, so it never reaches + * `tools/list`. + * + * @example Register only the function-backed tools + * ```ts + * const functionTools = Object.fromEntries( + * Object.entries(tools).filter(([, tool]) => tool._meta.kind === 'function'), + * ) + * registerTools(server, functionTools) + * ``` + * + * @category Types + */ +export interface ToolMeta { + /** `relation` for a table or view, `function` for a database function. */ + kind: 'relation' | 'function' + /** The table, view, or function the tool was generated from. */ + name: string + /** The HTTP verb the tool sends to PostgREST. */ + method: 'GET' | 'POST' | 'PATCH' | 'DELETE' +} + +/** + * A tool generated from the PostgREST description. The shape is what + * `McpServer.registerTool()` takes — `description`, `inputSchema`, + * `annotations` — plus the `handler` that runs it and {@link ToolMeta}. + * + * Generated tools are plain objects: replace a `handler`, `delete` an entry, + * or pick a subset with ordinary JavaScript before calling + * {@link registerTools}. + * + * @category Types + */ +export interface GeneratedTool { + /** + * The tool name, e.g. `list_notes`. Authoritative for registration; the key + * in the record {@link generateTools} returns is only an index. + */ + name: string + /** From `COMMENT ON` when present, otherwise built from the operation and name. */ + description: string + /** JSON Schema for the tool arguments, as PostgREST describes them. */ + inputSchema: Record + /** Read-only / destructive / idempotent / open-world hints for the operation. */ + annotations: ToolAnnotations + /** Provenance, for filtering before registration. */ + _meta: ToolMeta + /** Runs the operation through the Supabase client generation received. */ + handler: (args: Record) => Promise +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 6d2b164..7994566 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'src/middleware/client/index.ts', 'src/middleware/admin-client/index.ts', 'src/oauth-protected-resource/index.ts', + 'src/mcp/index.ts', ], format: ['esm', 'cjs'], dts: true, @@ -26,5 +27,6 @@ export default defineConfig({ 'elysia', '@nestjs/common', 'pg', + '@modelcontextprotocol/server', ], }) diff --git a/typedoc.json b/typedoc.json index 34d12e7..491d2a3 100644 --- a/typedoc.json +++ b/typedoc.json @@ -11,7 +11,8 @@ "src/middleware/postgres/index.ts", "src/middleware/postgres-admin/index.ts", "src/middleware/claims/index.ts", - "src/middleware/required-claims/index.ts" + "src/middleware/required-claims/index.ts", + "src/mcp/index.ts" ], "out": "api-docs", "json": "api-docs/spec.json", @@ -34,7 +35,8 @@ "docs/security.md", "docs/ssr-frameworks.md", "docs/postgres.md", - "docs/typescript-generics.md" + "docs/typescript-generics.md", + "docs/mcp.md" ], "highlightLanguages": [ "typescript", From a46e4eb692ca40de51996c804ce9c6be0e4aee5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Fri, 4 Sep 2026 12:53:23 +0200 Subject: [PATCH 2/6] refactor: generate.ts --- src/mcp/generate.test.ts | 32 ++++- src/mcp/generate.ts | 262 ++++++++++++++++++--------------------- 2 files changed, 146 insertions(+), 148 deletions(-) diff --git a/src/mcp/generate.test.ts b/src/mcp/generate.test.ts index 0ca2f09..fb80b6c 100644 --- a/src/mcp/generate.test.ts +++ b/src/mcp/generate.test.ts @@ -203,6 +203,28 @@ describe('generateToolDefinitions - which tools exist', () => { expect(tools.list_tasks.inputSchema.required).toBeUndefined() expect(tools.list_tasks.inputSchema.additionalProperties).toBe(false) }) + + it('shadows a column named like a pagination argument', () => { + const spec = { + ...probeSpec, + paths: { '/reports': { get: {} } }, + definitions: { + reports: { + properties: { + limit: { type: 'integer', format: 'int64' }, + title: { type: 'string', format: 'text' }, + }, + }, + }, + } + const { list_reports } = generateToolDefinitions(untouched(), spec) + + // The pagination argument wins, so the column cannot be filtered on. + expect(properties(list_reports).limit).toMatchObject({ + description: 'Maximum number of rows to return. Default 100.', + maximum: 1000, + }) + }) }) describe('generateToolDefinitions - descriptions and schemas', () => { @@ -455,17 +477,17 @@ describe('generateToolDefinitions - execution through the caller-scoped client', }) }) - it('list_ orders ascending by default and rejects unknown columns before any request', async () => { + it('list_ orders ascending by default and leaves the column to PostgREST', async () => { const { client, calls } = recordingClient() const tools = generateToolDefinitions(client, probeSpec) await tools.list_tasks.handler({ order: 'title' }) expect(calls[0].url.searchParams.get('order')).toBe('title.asc') - await expect( - tools.list_tasks.handler({ order: 'nope.desc' }), - ).rejects.toThrow(/Cannot order by "nope"/) - expect(calls).toHaveLength(1) + // No local column list to keep in step: PostgREST answers a column it + // cannot find with a 400, which the handler reports as a tool error. + await tools.list_tasks.handler({ order: 'nope' }) + expect(calls[1].url.searchParams.get('order')).toBe('nope.asc') }) it('get_ matches on the primary key and reports a missing row', async () => { diff --git a/src/mcp/generate.ts b/src/mcp/generate.ts index 42117a3..ecae884 100644 --- a/src/mcp/generate.ts +++ b/src/mcp/generate.ts @@ -56,15 +56,48 @@ type Client = SupabaseClient /** PostgREST marks primary-key columns with this literal in the description. */ const PK_MARKER = '' -/** `list_` arguments that are not column filters. */ -const RESERVED = new Set(['order', 'limit', 'offset']) - -const READ_ONLY: ToolAnnotations = { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, -} +/** Column types an equality filter makes sense on. */ +const FILTERABLE_TYPES = new Set(['string', 'integer', 'number', 'boolean']) + +/** + * One entry per operation, matching the annotation table in docs/mcp.md. They + * are advisory: they do not replace grants, Row Level Security, or + * application-level authorization. + */ +const ANNOTATIONS = { + read: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + create: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + update: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + delete: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + // A VOLATILE function may do anything, including reaching outside the + // project through an extension such as pg_net — hence openWorldHint. + call: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, +} satisfies Record /** * Generates one MCP tool per operation PostgREST exposes to the client's role. @@ -124,12 +157,13 @@ export async function fetchSpec( } function isSwaggerDocument(value: unknown): value is PostgrestOpenApiSpec { + const spec = value as PostgrestOpenApiSpec | null return ( - typeof value === 'object' && - value !== null && - typeof (value as { swagger?: unknown }).swagger === 'string' && - typeof (value as { paths?: unknown }).paths === 'object' && - typeof (value as { definitions?: unknown }).definitions === 'object' + typeof spec === 'object' && + spec !== null && + typeof spec.swagger === 'string' && + typeof spec.paths === 'object' && + typeof spec.definitions === 'object' ) } @@ -146,12 +180,15 @@ export function generateToolDefinitions( const client = supabase as unknown as Client const tools: Record = {} + const describe = ({ kind, name, method }: ToolMeta): string => + `${kind} "${name}" (${method})` + const add = (tool: GeneratedTool): void => { const existing = tools[tool.name] if (existing) { throw Errors[ToolNameCollisionError]({ name: tool.name, - operations: [describeMeta(existing._meta), describeMeta(tool._meta)], + operations: [describe(existing._meta), describe(tool._meta)], }) } tools[tool.name] = tool @@ -165,10 +202,6 @@ export function generateToolDefinitions( return tools } -function describeMeta(meta: ToolMeta): string { - return `${meta.kind} "${meta.name}" (${meta.method})` -} - // --------------------------------------------------------------------------- // Relations: tables and views // --------------------------------------------------------------------------- @@ -207,14 +240,14 @@ function relationTools( Object.fromEntries( names.map((column) => [column, columnSchema(properties[column])]), ) - const notFound = (key: Record): Error => - new Error(`No row of "${name}" matches ${JSON.stringify(key)}.`) const tools: GeneratedTool[] = [] if (verbs.has('get')) { - const filterable = columns.filter( - (column) => isScalar(properties[column]) && !RESERVED.has(column), + // A column named order, limit or offset is shadowed by the pagination + // argument of the same name, so it cannot be filtered on. + const filterable = columns.filter((column) => + FILTERABLE_TYPES.has(properties[column].type ?? ''), ) tools.push({ name: `list_${name}`, @@ -239,7 +272,7 @@ function relationTools( description: 'Number of rows to skip. Default 0.', }, }), - annotations: READ_ONLY, + annotations: ANNOTATIONS.read, _meta: { kind: 'relation', name, method: 'GET' }, handler: async (args) => { const { order, limit, offset, ...filters } = args @@ -249,8 +282,11 @@ function relationTools( query = query.eq(column, value) } if (typeof order === 'string' && order !== '') { - const sort = parseOrder(order, columns) - query = query.order(sort.column, { ascending: sort.ascending }) + // "created_at.desc" carries the direction; a column that does not + // exist is PostgREST's 400 to report, not ours to pre-empt. + query = query.order(order.replace(/\.(asc|desc)$/, ''), { + ascending: !order.endsWith('.desc'), + }) } const from = typeof offset === 'number' ? offset : 0 const size = typeof limit === 'number' ? limit : 100 @@ -264,18 +300,15 @@ function relationTools( name: `get_${name}`, description: describe(`Fetches one row of "${name}" by primary key.`), inputSchema: objectSchema(columnSchemas(primaryKey), primaryKey), - annotations: READ_ONLY, + annotations: ANNOTATIONS.read, _meta: { kind: 'relation', name, method: 'GET' }, handler: async (args) => { const key = pick(args, primaryKey) - const { data, error } = await client - .from(name) - .select('*') - .match(key) - .maybeSingle() - if (error) throw postgrestError(error) - if (data === null) throw notFound(key) - return textResult(data) + return toRowResult( + await client.from(name).select('*').match(key).maybeSingle(), + name, + key, + ) }, }) } @@ -286,20 +319,14 @@ function relationTools( // one at all — Postgres rejects it without OVERRIDING SYSTEM VALUE. const required = (definition.required ?? []).filter( (column) => - column in properties && - properties[column].default === undefined && + properties[column]?.default === undefined && !primaryKey.includes(column), ) tools.push({ name: `create_${name}`, description: describe(`Inserts one row into "${name}" and returns it.`), inputSchema: objectSchema(columnSchemas(columns), required), - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, - }, + annotations: ANNOTATIONS.create, _meta: { kind: 'relation', name, method: 'POST' }, handler: async (args) => toResult(await client.from(name).insert(args).select('*').single()), @@ -313,12 +340,7 @@ function relationTools( `Updates one row of "${name}" by primary key and returns it. Only the columns given change.`, ), inputSchema: objectSchema(columnSchemas(columns), primaryKey), - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false, - }, + annotations: ANNOTATIONS.update, _meta: { kind: 'relation', name, method: 'PATCH' }, handler: async (args) => { const key = pick(args, primaryKey) @@ -333,15 +355,16 @@ function relationTools( `Nothing to update in "${name}": pass at least one column besides the primary key.`, ) } - const { data, error } = await client - .from(name) - .update(changes) - .match(key) - .select('*') - .maybeSingle() - if (error) throw postgrestError(error) - if (data === null) throw notFound(key) - return textResult(data) + return toRowResult( + await client + .from(name) + .update(changes) + .match(key) + .select('*') + .maybeSingle(), + name, + key, + ) }, }) } @@ -353,24 +376,15 @@ function relationTools( `Deletes one row of "${name}" by primary key and returns it.`, ), inputSchema: objectSchema(columnSchemas(primaryKey), primaryKey), - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: false, - }, + annotations: ANNOTATIONS.delete, _meta: { kind: 'relation', name, method: 'DELETE' }, handler: async (args) => { const key = pick(args, primaryKey) - const { data, error } = await client - .from(name) - .delete() - .match(key) - .select('*') - .maybeSingle() - if (error) throw postgrestError(error) - if (data === null) throw notFound(key) - return textResult(data) + return toRowResult( + await client.from(name).delete().match(key).select('*').maybeSingle(), + name, + key, + ) }, }) } @@ -378,20 +392,6 @@ function relationTools( return tools } -function parseOrder( - order: string, - columns: string[], -): { column: string; ascending: boolean } { - const match = /^(.+?)(?:\.(asc|desc))?$/.exec(order) - const column = match?.[1] ?? order - if (!columns.includes(column)) { - throw new Error( - `Cannot order by "${column}": not a column. Columns: ${columns.join(', ')}.`, - ) - } - return { column, ascending: match?.[2] !== 'desc' } -} - // --------------------------------------------------------------------------- // Database functions: /rpc/ // --------------------------------------------------------------------------- @@ -410,9 +410,12 @@ function databaseFunctions(spec: PostgrestOpenApiSpec): DatabaseFunction[] { if (!path.startsWith('/rpc/')) continue const post = operations.post as SwaggerOperation | undefined if (!post) continue - const body = (post.parameters ?? []) - .map((parameter) => resolveParameter(spec, parameter)) - .find((parameter) => parameter?.in === 'body') + // PostgREST inlines a function's arguments in the body parameter. The only + // `$ref` in an rpc operation points at a header (`preferParams`). + const body = (post.parameters ?? []).find( + (parameter): parameter is SwaggerParameter => + !('$ref' in parameter) && parameter.in === 'body', + ) found.push({ name: path.slice('/rpc/'.length), hasGet: 'get' in operations, @@ -423,18 +426,6 @@ function databaseFunctions(spec: PostgrestOpenApiSpec): DatabaseFunction[] { return found } -/** Resolves a `#/parameters/` reference one level; inline values pass through. */ -function resolveParameter( - spec: PostgrestOpenApiSpec, - parameter: SwaggerParameter | { $ref: string }, -): SwaggerParameter | undefined { - if (!('$ref' in parameter)) return parameter - const match = /^#\/parameters\/(.+)$/.exec(parameter.$ref) - if (!match) return undefined - const key = match[1].replace(/~1/g, '/').replace(/~0/g, '~') - return spec.parameters?.[key] as SwaggerParameter | undefined -} - function functionTool(client: Client, fn: DatabaseFunction): GeneratedTool { const properties = fn.args.properties ?? {} return { @@ -451,16 +442,7 @@ function functionTool(client: Client, fn: DatabaseFunction): GeneratedTool { ), fn.args.required, ), - // A VOLATILE function may do anything, including reaching outside the - // project through an extension such as pg_net — hence openWorldHint. - annotations: fn.hasGet - ? READ_ONLY - : { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: true, - }, + annotations: fn.hasGet ? ANNOTATIONS.read : ANNOTATIONS.call, _meta: { kind: 'function', name: fn.name, @@ -475,39 +457,17 @@ function functionTool(client: Client, fn: DatabaseFunction): GeneratedTool { // Swagger → JSON Schema // --------------------------------------------------------------------------- -const JSON_SCHEMA_TYPES = new Set([ - 'string', - 'integer', - 'number', - 'boolean', - 'array', - 'object', -]) - -function isScalar(property: SwaggerProperty): boolean { - return ( - property.type === 'string' || - property.type === 'integer' || - property.type === 'number' || - property.type === 'boolean' - ) -} - /** - * The Swagger property as JSON Schema. `type` and `enum` carry over; `format` - * (which varies by PostgREST version) and the SQL `default` go into the - * description for the model's benefit, never as validation keywords. + * The Swagger property as JSON Schema. PostgREST reports `type` from a closed + * mapping of Postgres types, and every value in it is already a JSON Schema + * type, so `type` and `enum` carry over as they are. `format` (which varies by + * PostgREST version) and the SQL `default` go into the description for the + * model's benefit, never as validation keywords. */ function columnSchema(property: SwaggerProperty): JsonSchema { const schema: JsonSchema = {} - if (property.type && JSON_SCHEMA_TYPES.has(property.type)) { - schema.type = property.type - } - if ( - property.type === 'array' && - property.items?.type && - JSON_SCHEMA_TYPES.has(property.items.type) - ) { + if (property.type) schema.type = property.type + if (property.type === 'array' && property.items?.type) { schema.items = { type: property.items.type } } if (property.enum) schema.enum = property.enum @@ -544,8 +504,7 @@ function objectSchema( function cleanDescription(text: string | undefined): string | undefined { if (!text) return undefined const cleaned = text - .replace(//g, '') - .replace(/]*\/>/g, '') + .replace(/|]*\/>/g, '') .split('\n') .map((line) => line.trim()) .filter( @@ -582,6 +541,23 @@ function toResult(response: { return textResult(response.data) } +/** + * The same, for the operations that address one row by primary key. + * `maybeSingle()` reports a missing row as `data: null`, which for a key lookup + * is a tool error rather than an empty result. + */ +function toRowResult( + response: { data: unknown; error: PostgrestError | null }, + relation: string, + key: Record, +): CallToolResult { + if (response.error) throw postgrestError(response.error) + if (response.data === null) { + throw new Error(`No row of "${relation}" matches ${JSON.stringify(key)}.`) + } + return textResult(response.data) +} + /** * A thrown error becomes a tool error in the MCP SDK. The message carries * everything PostgREST said so the model can act on it. From 101f8db7762abb6b97bdba6bb6e4ba4b5eba9d39 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 9 Sep 2026 11:09:45 +0300 Subject: [PATCH 3/6] chore: update middleware to latest --- package.json | 2 +- pnpm-lock.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 95e203b..86326bd 100644 --- a/package.json +++ b/package.json @@ -246,6 +246,7 @@ "@nestjs/platform-express": "^11.1.19", "@nestjs/platform-fastify": "^11.1.19", "@nestjs/testing": "^11.1.19", + "@supabase/middleware": "^0.5.0", "@supabase/supabase-js": "^2.115.0", "@swc/core": "^1.15.33", "@types/node": "^26.0.1", @@ -271,7 +272,6 @@ "vitest": "^5.0.0" }, "dependencies": { - "@supabase/middleware": "^0.5.0", "jose": "^6.2.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91567e5..67a4b8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: .: dependencies: - '@supabase/middleware': - specifier: ^0.5.0 - version: 0.5.0(typescript@5.9.3) jose: specifier: ^6.2.0 version: 6.2.0 @@ -45,6 +42,9 @@ importers: '@nestjs/testing': specifier: ^11.1.19 version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19) + '@supabase/middleware': + specifier: ^0.5.0 + version: 0.5.0(typescript@5.9.3) '@supabase/supabase-js': specifier: ^2.115.0 version: 2.115.0 From 803a41aaef541f2545b220a953d555351652fa6e Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 9 Sep 2026 14:40:25 +0300 Subject: [PATCH 4/6] chore: update supabase-js --- package.json | 2 +- pnpm-lock.yaml | 63 +++++++++++++++++++++++--------------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 86326bd..8636fa1 100644 --- a/package.json +++ b/package.json @@ -247,7 +247,7 @@ "@nestjs/platform-fastify": "^11.1.19", "@nestjs/testing": "^11.1.19", "@supabase/middleware": "^0.5.0", - "@supabase/supabase-js": "^2.115.0", + "@supabase/supabase-js": "^2.116.0", "@swc/core": "^1.15.33", "@types/node": "^26.0.1", "@types/pg": "^8.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67a4b8a..c469fcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,8 +46,8 @@ importers: specifier: ^0.5.0 version: 0.5.0(typescript@5.9.3) '@supabase/supabase-js': - specifier: ^2.115.0 - version: 2.115.0 + specifier: ^2.116.0 + version: 2.116.0 '@swc/core': specifier: ^1.15.33 version: 1.15.33 @@ -833,15 +833,12 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@supabase/auth-js@2.115.0': - resolution: {integrity: sha512-YNQlQWm1H0gsXHSY8Jd/xepBhjO0Zhwx04iW17A83/joQ5kFiUin6iPj9s9kZZvupnwLjXVP/diTFiLz2jUbwQ==} + '@supabase/auth-js@2.116.0': + resolution: {integrity: sha512-Cmosty12gyKGK9N3bQb+lMmuAFev5nmUzaR1AsmZHqKOAGzqX1VQzmp49CNPwOx/pw0H9Qqk4rs9yhwTlKpfDg==} engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.115.0': - resolution: {integrity: sha512-p97V6/YFcdp+zblFDVJaE8f9rGKTNz0PRzyJ2d1w/EYIU5lwidKuc3l/wM+u27ACmAC62albvoGiUGzLPSg7Aw==} + '@supabase/functions-js@2.116.0': + resolution: {integrity: sha512-E+VOc2QDcni/fySqkBFiZhnoB3SGydEdZgFI6/dEAGAHx6yEhB46TN9qb2wXs+E+RSzOBV0R6dasiSlw4xlZAA==} engines: {node: '>=22.0.0'} '@supabase/middleware@0.5.0': @@ -856,20 +853,20 @@ packages: '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - '@supabase/postgrest-js@2.115.0': - resolution: {integrity: sha512-DdERcurLh5t84pgSywDg2LjLR5le7XAzl52/iQhC7FdboWDqGfDPMfaWJV3MHvhyxSlSNzbpSQG+RiQOBSn/4w==} + '@supabase/postgrest-js@2.116.0': + resolution: {integrity: sha512-kGpVZTDHxFTJS3tu+rU0iTAZ+4U0bcLVjxwCk8f3gRhjw3qdCZjTBlgYvc4kGH2XccmAzbkKwXL/mrNHMGSc+A==} engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.115.0': - resolution: {integrity: sha512-5HyBkvlA/IUV2v8jX3uLTdy15jmTPRVXwXKoxvH2SKw5jZMNURxCxt+VpCEukscXQlY7pUpH8Cy4NH6io4iaRw==} + '@supabase/realtime-js@2.116.0': + resolution: {integrity: sha512-MHAnlXxi2s6yiJsZsQMfs2B3RFxeVfQWxerqYhIMqcCQV/FuY3LIeouPEkXw/ah7wUWMLYwempF9MOCUScyddg==} engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.115.0': - resolution: {integrity: sha512-dLyIxzbO+MCcKHhcce8rVUCQX1iyqXqQ8ytgkOVYJ7D+Zp0qKylPtQH3hamgxrGSxtDjaw47Urpzw2iK9PsKdA==} + '@supabase/storage-js@2.116.0': + resolution: {integrity: sha512-6/3hR6vccBP6oGM5B6RfbwZcTCKmQOodd/ZWQdsw8yJsU5zO/a//oBL6yLnmgxcjnHSrelW8rsO7hL5DPybyUQ==} engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.115.0': - resolution: {integrity: sha512-PYJSxtCo37R7tTZW6pAqsxUeSx/dlhA7zn8RzKEUSCqyTxCUhG+iHrDTb04UyVBYbttaUbhwkNTvb8h5HW+uZA==} + '@supabase/supabase-js@2.116.0': + resolution: {integrity: sha512-YyWmKXt2NspV9iO8FPnlswUFJIRnrLd3oTCb+3ZyYRuKZtBH0xCUDgnUqoyA0fGUxpM/UhfwDjYf/dht/9bp7g==} engines: {node: '>=22.0.0'} peerDependencies: '@opentelemetry/api': '>=1.0.0' @@ -2262,8 +2259,8 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true - postcss@8.5.28: - resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -3470,13 +3467,11 @@ snapshots: '@sindresorhus/is@4.6.0': {} - '@standard-schema/spec@1.1.0': {} - - '@supabase/auth-js@2.115.0': + '@supabase/auth-js@2.116.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.115.0': + '@supabase/functions-js@2.116.0': dependencies: tslib: 2.8.1 @@ -3488,27 +3483,27 @@ snapshots: '@supabase/phoenix@0.4.5': {} - '@supabase/postgrest-js@2.115.0': + '@supabase/postgrest-js@2.116.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.115.0': + '@supabase/realtime-js@2.116.0': dependencies: '@supabase/phoenix': 0.4.5 tslib: 2.8.1 - '@supabase/storage-js@2.115.0': + '@supabase/storage-js@2.116.0': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.115.0': + '@supabase/supabase-js@2.116.0': dependencies: - '@supabase/auth-js': 2.115.0 - '@supabase/functions-js': 2.115.0 - '@supabase/postgrest-js': 2.115.0 - '@supabase/realtime-js': 2.115.0 - '@supabase/storage-js': 2.115.0 + '@supabase/auth-js': 2.116.0 + '@supabase/functions-js': 2.116.0 + '@supabase/postgrest-js': 2.116.0 + '@supabase/realtime-js': 2.116.0 + '@supabase/storage-js': 2.116.0 '@swc/core-darwin-arm64@1.15.33': optional: true @@ -4804,7 +4799,7 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 - postcss@8.5.28: + postcss@8.5.26: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 @@ -5295,7 +5290,7 @@ snapshots: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 - postcss: 8.5.28 + postcss: 8.5.26 rollup: 4.63.1 tinyglobby: 0.2.17 optionalDependencies: From 8a587338e0594036a05e4349f6cc2449d58e4862 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 9 Sep 2026 14:56:40 +0300 Subject: [PATCH 5/6] chore: restore middleware to deps --- package.json | 2 +- pnpm-lock.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 8636fa1..8829064 100644 --- a/package.json +++ b/package.json @@ -246,7 +246,6 @@ "@nestjs/platform-express": "^11.1.19", "@nestjs/platform-fastify": "^11.1.19", "@nestjs/testing": "^11.1.19", - "@supabase/middleware": "^0.5.0", "@supabase/supabase-js": "^2.116.0", "@swc/core": "^1.15.33", "@types/node": "^26.0.1", @@ -272,6 +271,7 @@ "vitest": "^5.0.0" }, "dependencies": { + "@supabase/middleware": "^0.5.0", "jose": "^6.2.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c469fcc..2250c3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@supabase/middleware': + specifier: ^0.5.0 + version: 0.5.0(typescript@5.9.3) jose: specifier: ^6.2.0 version: 6.2.0 @@ -42,9 +45,6 @@ importers: '@nestjs/testing': specifier: ^11.1.19 version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19) - '@supabase/middleware': - specifier: ^0.5.0 - version: 0.5.0(typescript@5.9.3) '@supabase/supabase-js': specifier: ^2.116.0 version: 2.116.0 From 25e3be999da45aa36fb8740f27888b581473b4ec Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 9 Sep 2026 15:01:36 +0300 Subject: [PATCH 6/6] docs(mcp): show the pipeline form and note entry ordering --- docs/mcp.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/mcp.md b/docs/mcp.md index 18a840c..b158b6a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -26,6 +26,31 @@ Deno.serve( ) ``` +The same server as `pipeline` entries: + +```ts +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' +import { pipeline } from '@supabase/middleware' +import { withOAuthProtectedResource, withSupabase } from '@supabase/server' +import { generateTools, registerTools } from '@supabase/server/mcp' + +Deno.serve( + pipeline( + [withOAuthProtectedResource(), withSupabase({ auth: 'user' })], + async (req, { supabase }) => { + const handler = createMcpHandler(async () => { + const server = new McpServer({ name: 'notes-mcp', version: '0.1.0' }) + registerTools(server, await generateTools(supabase)) + return server + }) + return handler.fetch(req) + }, + ), +) +``` + +Order matters in both forms. The OAuth discovery request carries no token, so `withOAuthProtectedResource` must run before `withSupabase` checks for one: it wraps `withSupabase` in the first form and comes first in the array in the second. The reverse order is refused when the stack is built. + ## Requirements | Dependency | Version | Why |