From c6eefcb90149e054269ee38e68ea9741b71e63d3 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 11 Aug 2026 14:51:26 +0200 Subject: [PATCH 1/5] feat: serve Supabase MCP over both protocol eras Move the CLI's stdio entry from a one-shot server plus raw StdioServerTransport onto the SDK's serveStdio, which owns transport startup, connection-pinned era selection, and teardown. The default legacy: 'serve' is the dual-era behavior we want, so no option is passed. CLI parsing, token handling, platform construction, error output, and exit codes are unchanged. Export createSupabaseMcpHandler, a thin wrapper over the SDK's createMcpHandler with legacy: 'reject'. Hosted owns authentication, ABAC, era dispatch, logging, and request lifetime, so the package interface carries only the strict modern entry and server construction. The stdio wire goldens now run as an era matrix. Both eras assert the same 29-tool set, the same server identity and capabilities, and the same list_projects content. Modern additionally carries the protocol's mandatory _meta['io.modelcontextprotocol/serverInfo'] stamp, which legacy asserts is absent. Add test:packed-platform-consumer, which packs mcp-server-supabase and mcp-utils, installs both with plain npm into a throwaway project pinned to the exact zod version Platform's catalog carries, then asserts the ESM entry, the CJS entry, the type declarations, and one modern 2026-07-28 call. Registering the account tool group keeps the zod-built tool surface under test, which an empty catalog would skip. --- package.json | 1 + packages/mcp-server-supabase/src/index.ts | 1 + .../src/transports/http.test.ts | 231 ++++++++++ .../src/transports/http.ts | 12 + .../src/transports/stdio.ts | 22 +- .../test/stdio.integration.ts | 82 ++-- scripts/test-packed-platform-consumer.mjs | 429 ++++++++++++++++++ 7 files changed, 739 insertions(+), 39 deletions(-) create mode 100644 packages/mcp-server-supabase/src/transports/http.test.ts create mode 100644 packages/mcp-server-supabase/src/transports/http.ts create mode 100644 scripts/test-packed-platform-consumer.mjs diff --git a/package.json b/package.json index 73d9efa1..f9045d28 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "build": "pnpm --filter @supabase/mcp-utils --filter @supabase/mcp-server-supabase --filter @supabase/mcp-server-postgrest build", "test": "pnpm --parallel --filter @supabase/mcp-utils --filter @supabase/mcp-server-supabase --filter @supabase/mcp-server-postgrest test", "test:coverage": "pnpm --filter @supabase/mcp-server-supabase test:coverage", + "test:packed-platform-consumer": "node scripts/test-packed-platform-consumer.mjs", "format": "biome check --write .", "format:check": "biome check ." }, diff --git a/packages/mcp-server-supabase/src/index.ts b/packages/mcp-server-supabase/src/index.ts index c685bc25..bc20ab46 100644 --- a/packages/mcp-server-supabase/src/index.ts +++ b/packages/mcp-server-supabase/src/index.ts @@ -6,6 +6,7 @@ export { createSupabaseMcpServer, type SupabaseMcpServerOptions, } from './server.js'; +export { createSupabaseMcpHandler } from './transports/http.js'; export { CURRENT_FEATURE_GROUPS, type FeatureGroup, diff --git a/packages/mcp-server-supabase/src/transports/http.test.ts b/packages/mcp-server-supabase/src/transports/http.test.ts new file mode 100644 index 00000000..3a3f0711 --- /dev/null +++ b/packages/mcp-server-supabase/src/transports/http.test.ts @@ -0,0 +1,231 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { + CLIENT_CAPABILITIES_META_KEY, + PROTOCOL_VERSION_META_KEY, +} from '@modelcontextprotocol/server'; +import { http, HttpResponse } from 'msw'; +import { setupServer, type SetupServer } from 'msw/node'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { + ACCESS_TOKEN, + API_URL, + MCP_CLIENT_NAME, + MCP_CLIENT_VERSION, + mockBranches, + mockContentApi, + mockContentApiSchemaLoadCount, + mockManagementApi, + mockOrgs, + mockProjects, +} from '../../test/mocks.js'; +import { createSupabaseApiPlatform } from '../platform/api-platform.js'; +import { createSupabaseMcpHandler } from './http.js'; + +const MODERN_PROTOCOL_VERSION = '2026-07-28'; +const MCP_ENDPOINT = new URL('https://mcp.test'); + +let mockServer: SetupServer | undefined; +const cleanups: Array<() => Promise> = []; + +beforeEach(() => { + mockOrgs.clear(); + mockProjects.clear(); + mockBranches.clear(); + mockContentApiSchemaLoadCount.value = 0; + + mockServer = setupServer(...mockContentApi, ...mockManagementApi); + mockServer.listen({ onUnhandledRequest: 'error' }); +}); + +afterEach(async () => { + try { + for (const cleanup of cleanups.splice(0).reverse()) { + await cleanup(); + } + } finally { + mockServer?.close(); + } +}); + +function createPlatform() { + return createSupabaseApiPlatform({ + accessToken: ACCESS_TOKEN, + apiUrl: API_URL, + }); +} + +function createHandler(platform = createPlatform()) { + const handler = createSupabaseMcpHandler({ platform, readOnly: true }); + + cleanups.push(() => handler.close()); + + return handler; +} + +async function setupModernClient() { + const handler = createHandler(); + const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, { + fetch: (url, init) => handler.fetch(new Request(url, init)), + }); + const client = new Client( + { + name: MCP_CLIENT_NAME, + version: MCP_CLIENT_VERSION, + }, + { + capabilities: {}, + versionNegotiation: { + mode: { pin: MODERN_PROTOCOL_VERSION }, + }, + } + ); + + await client.connect(transport); + cleanups.push(() => client.close()); + + return { client, handler }; +} + +function jsonRequest(body: unknown) { + return new Request(MCP_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + + return { promise, resolve }; +} + +describe('createSupabaseMcpHandler', () => { + test('serves discovery and tools/list to a client pinned to 2026-07-28', async () => { + const { client } = await setupModernClient(); + + const { tools } = await client.listTools(); + + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_PROTOCOL_VERSION); + expect(client.getDiscoverResult()?.supportedVersions).toContain( + MODERN_PROTOCOL_VERSION + ); + expect(tools.map((tool) => tool.name)).toContain('list_projects'); + }); + + test('calls the same registered read-only business tool', async () => { + const { client } = await setupModernClient(); + + const result = await client.callTool({ + name: 'search_docs', + arguments: { + graphql_query: + '{ searchDocs(query: "typescript") { nodes { title href } } }', + }, + }); + + expect(result.isError).not.toBe(true); + expect(result.content).toEqual([ + { + type: 'text', + text: JSON.stringify({ result: { dummy: true } }), + }, + ]); + }); + + test('rejects a claim-less legacy request', async () => { + const handler = createHandler(); + + const response = await handler.fetch( + jsonRequest({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }) + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: -32022, + message: + 'Unsupported protocol version: the request did not name a protocol version', + data: { supported: [MODERN_PROTOCOL_VERSION] }, + }, + }); + }); + + test('returns a modern validation error for a malformed claimed envelope', async () => { + const handler = createHandler(); + + const response = await handler.fetch( + jsonRequest({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_PROTOCOL_VERSION, + }, + }, + }) + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + jsonrpc: '2.0', + id: 2, + error: { + code: -32602, + message: `Invalid _meta envelope for protocol revision 2026-07-28: ${CLIENT_CAPABILITIES_META_KEY}: missing`, + data: { + envelope: { + key: CLIENT_CAPABILITIES_META_KEY, + problem: 'missing', + }, + }, + }, + }); + }); + + test('close releases an in-flight request', async () => { + const requestStarted = deferred(); + const releaseRequest = deferred(); + mockServer?.use( + http.get(`${API_URL}/v1/projects`, async () => { + requestStarted.resolve(); + await releaseRequest.promise; + return HttpResponse.json([]); + }) + ); + const { client, handler } = await setupModernClient(); + const callOutcome = client + .callTool({ name: 'list_projects', arguments: {} }) + .then( + () => ({ status: 'resolved' as const }), + (error: unknown) => ({ status: 'rejected' as const, error }) + ); + + try { + await requestStarted.promise; + await handler.close(); + + await expect(callOutcome).resolves.toMatchObject({ + status: 'rejected', + }); + } finally { + releaseRequest.resolve(); + } + }); +}); diff --git a/packages/mcp-server-supabase/src/transports/http.ts b/packages/mcp-server-supabase/src/transports/http.ts new file mode 100644 index 00000000..4ab05484 --- /dev/null +++ b/packages/mcp-server-supabase/src/transports/http.ts @@ -0,0 +1,12 @@ +import { createMcpHandler } from '@modelcontextprotocol/server'; + +import { + createSupabaseMcpServer, + type SupabaseMcpServerOptions, +} from '../server.js'; + +export function createSupabaseMcpHandler(options: SupabaseMcpServerOptions) { + return createMcpHandler(() => createSupabaseMcpServer(options), { + legacy: 'reject', + }); +} diff --git a/packages/mcp-server-supabase/src/transports/stdio.ts b/packages/mcp-server-supabase/src/transports/stdio.ts index ff5420fb..dc828d46 100644 --- a/packages/mcp-server-supabase/src/transports/stdio.ts +++ b/packages/mcp-server-supabase/src/transports/stdio.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { parseArgs } from 'node:util'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import packageJson from '../../package.json' with { type: 'json' }; import { createSupabaseApiPlatform } from '../platform/api-platform.js'; @@ -71,17 +71,15 @@ async function main() { apiUrl, }); - const server = createSupabaseMcpServer({ - platform, - projectId, - readOnly, - features, - contentApiUrl, - }); - - const transport = new StdioServerTransport(); - - await server.connect(transport); + serveStdio(() => + createSupabaseMcpServer({ + platform, + projectId, + readOnly, + features, + contentApiUrl, + }) + ); } main().catch(console.error); diff --git a/packages/mcp-server-supabase/test/stdio.integration.ts b/packages/mcp-server-supabase/test/stdio.integration.ts index 871af50f..14df24f2 100644 --- a/packages/mcp-server-supabase/test/stdio.integration.ts +++ b/packages/mcp-server-supabase/test/stdio.integration.ts @@ -11,7 +11,10 @@ import { MCP_SERVER_VERSION, } from './mocks.js'; +type ProtocolEra = 'legacy' | 'modern'; + type SetupOptions = { + era?: ProtocolEra; accessToken?: string; projectId?: string; readOnly?: boolean; @@ -23,6 +26,7 @@ type SetupOptions = { async function setup(options: SetupOptions = {}) { const { accessToken = ACCESS_TOKEN, + era = 'legacy', projectId, readOnly, apiUrl, @@ -37,6 +41,8 @@ async function setup(options: SetupOptions = {}) { }, { capabilities: {}, + versionNegotiation: + era === 'modern' ? { mode: { pin: '2026-07-28' } } : { mode: 'legacy' }, } ); @@ -175,7 +181,7 @@ describe('stdio', () => { await Promise.all(stubs.splice(0).map((stub) => stub.close())); }); - test('server connects and lists tools', async () => { + async function assertServerContract(era: ProtocolEra) { const managementApiStub = await createManagementApiStub(); const contentApiStub = await createContentApiStub(); stubs.push(managementApiStub, contentApiStub); @@ -183,6 +189,7 @@ describe('stdio', () => { const { client } = await setup({ apiUrl: managementApiStub.url, contentApiUrl: contentApiStub.url, + era, }); try { @@ -231,33 +238,49 @@ describe('stdio', () => { expect(client.getServerCapabilities()).toEqual({ tools: {}, }); - expect(toolResult).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify({ - projects: [ - { - id: 'abcdefghijklmnopqrst', - ref: 'abcdefghijklmnopqrst', - organization_id: 'tsrqponmlkjihgfedcba', - organization_slug: 'tsrqponmlkjihgfedcba', - name: 'Example project', - region: 'us-east-1', - created_at: '2024-01-02T03:04:05.000Z', - status: 'ACTIVE_HEALTHY', - database: { - host: 'db.abcdefghijklmnopqrst.supabase.co', - version: '15.1.0.147', - postgres_engine: '15', - release_channel: 'ga', - }, + const expectedToolContent = [ + { + type: 'text', + text: JSON.stringify({ + projects: [ + { + id: 'abcdefghijklmnopqrst', + ref: 'abcdefghijklmnopqrst', + organization_id: 'tsrqponmlkjihgfedcba', + organization_slug: 'tsrqponmlkjihgfedcba', + name: 'Example project', + region: 'us-east-1', + created_at: '2024-01-02T03:04:05.000Z', + status: 'ACTIVE_HEALTHY', + database: { + host: 'db.abcdefghijklmnopqrst.supabase.co', + version: '15.1.0.147', + postgres_engine: '15', + release_channel: 'ga', }, - ], - }), + }, + ], + }), + }, + ]; + + if (era === 'modern') { + expect(toolResult).toEqual({ + _meta: { + 'io.modelcontextprotocol/serverInfo': { + name: 'supabase', + title: 'Supabase', + version: MCP_SERVER_VERSION, + }, }, - ], - }); + content: expectedToolContent, + }); + } else { + expect(toolResult).not.toHaveProperty('_meta'); + expect(toolResult).toEqual({ + content: expectedToolContent, + }); + } expect( managementApiStub.hits.map(({ method, url }) => ({ method, @@ -275,7 +298,12 @@ describe('stdio', () => { } finally { await client.close(); } - }); + } + + test.each(['legacy', 'modern'])( + 'server connects and lists tools (%s)', + assertServerContract + ); test('missing access token fails', async () => { const setupPromise = setup({ accessToken: null as any }); diff --git a/scripts/test-packed-platform-consumer.mjs b/scripts/test-packed-platform-consumer.mjs new file mode 100644 index 00000000..edc1a561 --- /dev/null +++ b/scripts/test-packed-platform-consumer.mjs @@ -0,0 +1,429 @@ +// Proves that a Platform-shaped consumer can install the *published* +// @supabase/mcp-server-supabase package (plus its workspace dependency +// @supabase/mcp-utils) from real npm tarballs, on Platform's pinned zod +// version, entirely outside this pnpm workspace -- then exercises the +// package's public surface end to end. +// +// This is a packaging gate, not a unit test: it packs, installs with plain +// npm in a throwaway project outside the repo tree, and drives the packed +// artifact for real. Inspecting package.json/exports maps is explicitly not +// enough -- see AI-1044. +// +// Run with: pnpm test:packed-platform-consumer + +import { execFileSync } from 'node:child_process'; +import { + lstatSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// --------------------------------------------------------------------------- +// Platform's pinned zod version. `supabase/platform`'s `develop` catalog pins +// zod to exactly this version today (checked 2026-08-11 at commit +// 603f39cb4c). The SDK only requires `zod: ^4.2.0`, so a Platform-shaped +// consumer must install cleanly on this pin. Bump this one constant -- and +// nothing else -- when Platform's catalog pin changes. +const PLATFORM_ZOD_VERSION = '4.4.3'; +// --------------------------------------------------------------------------- + +// The stable SDK release this repo is built against (see AI-1044). Pinned +// exactly, rather than left as a range, so this gate can't silently start +// exercising a newer SDK release than the one the package actually targets. +const SDK_VERSION = '2.0.0'; + +// TypeScript used to typecheck the packed `.d.ts` surface in the fixture. +// Matches the range the workspace itself develops against. +const TYPESCRIPT_VERSION_RANGE = '^5.6.3'; + +const MODERN_PROTOCOL_VERSION = '2026-07-28'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); + +const SUPABASE_PACKAGE_NAME = '@supabase/mcp-server-supabase'; +const UTILS_PACKAGE_NAME = '@supabase/mcp-utils'; + +const TSCONFIG = { + compilerOptions: { + strict: true, + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + skipLibCheck: true, + noEmit: true, + esModuleInterop: true, + forceConsistentCasingInFileNames: true, + }, + include: ['types-check.ts'], +}; + +const ESM_CHECK_SOURCE = `import { createSupabaseMcpHandler } from '${SUPABASE_PACKAGE_NAME}'; + +if (typeof createSupabaseMcpHandler !== 'function') { + throw new Error( + \`expected createSupabaseMcpHandler to be a function, got \${typeof createSupabaseMcpHandler}\` + ); +} + +console.log('ESM_OK'); +`; + +const CJS_CHECK_SOURCE = `const { createSupabaseMcpHandler } = require('${SUPABASE_PACKAGE_NAME}'); + +if (typeof createSupabaseMcpHandler !== 'function') { + throw new Error( + \`expected createSupabaseMcpHandler to be a function, got \${typeof createSupabaseMcpHandler}\` + ); +} + +console.log('CJS_OK'); +`; + +const TYPES_CHECK_SOURCE = `import { + createSupabaseMcpHandler, + type SupabaseMcpServerOptions, +} from '${SUPABASE_PACKAGE_NAME}'; + +// A stubbed \`account\` platform, whose seven operations only ever run on a +// tool call and so can reject here. It buys the thing that matters: asking +// for the \`account\` feature group makes the server register real tools, so +// the typecheck covers the zod-backed tool surface rather than an empty one. +const account: SupabaseMcpServerOptions['platform']['account'] = { + listOrganizations: () => Promise.reject(new Error('not implemented')), + getOrganization: () => Promise.reject(new Error('not implemented')), + listProjects: () => Promise.reject(new Error('not implemented')), + getProject: () => Promise.reject(new Error('not implemented')), + createProject: () => Promise.reject(new Error('not implemented')), + pauseProject: () => Promise.reject(new Error('not implemented')), + restoreProject: () => Promise.reject(new Error('not implemented')), +}; + +const options: SupabaseMcpServerOptions = { + platform: { account }, + features: ['account'], +}; + +const handler = createSupabaseMcpHandler(options); + +// Touch every member of the public McpHttpHandler shape so a signature +// change here fails the typecheck, not just a missing export. +void handler.fetch; +void handler.close; +void handler.notify; +void handler.bus; +`; + +const MODERN_CALL_SOURCE = `import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createSupabaseMcpHandler } from '${SUPABASE_PACKAGE_NAME}'; + +const MODERN_PROTOCOL_VERSION = '${MODERN_PROTOCOL_VERSION}'; + +// Stubbed \`account\` operations, which only run on a tool call, paired with +// \`features: ['account']\` so the server registers its real zod-built tool +// schemas. That is the path a zod version skew between Platform's catalog and +// the SDK's own dependency would break, so an empty catalog would not test it. +// \`docs\` stays out: its tool description lazily calls supabase.com, and this +// exchange must never leave the process. +const notImplemented = () => Promise.reject(new Error('not implemented')); + +const handler = createSupabaseMcpHandler({ + platform: { + account: { + listOrganizations: notImplemented, + getOrganization: notImplemented, + listProjects: notImplemented, + getProject: notImplemented, + createProject: notImplemented, + pauseProject: notImplemented, + restoreProject: notImplemented, + }, + }, + features: ['account'], +}); + +const transport = new StreamableHTTPClientTransport( + new URL('http://packed-platform-consumer-fixture.invalid/mcp'), + { + // Routes every request straight into the handler's fetch face in-process. + fetch: (url, init) => handler.fetch(new Request(url, init)), + } +); + +const client = new Client( + { name: 'packed-platform-consumer-fixture', version: '0.0.0' }, + { versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } } } +); + +await client.connect(transport); +const { tools } = await client.listTools(); + +// A real tool carrying a real input schema, so this proves the zod-built tool +// surface survives Platform's zod version. An empty array would pass a +// transport round trip while testing none of that. +const listProjects = tools.find((tool) => tool.name === 'list_projects'); + +if (!listProjects?.inputSchema) { + throw new Error( + \`tools/list did not return list_projects with an input schema (got: \${JSON.stringify(tools.map((tool) => tool.name))})\` + ); +} + +await client.close(); +await handler.close(); + +console.log(\`MODERN_CALL_OK tools=\${tools.length}\`); +`; + +/** Runs a command, streaming its own stdout/stderr straight to the console. */ +function runVisible(command, args, cwd) { + execFileSync(command, args, { cwd, stdio: 'inherit' }); +} + +/** Packs one workspace package into `destDir`, returning its packed manifest. */ +function packWorkspacePackage(packageName, destDir) { + const stdout = execFileSync( + 'pnpm', + ['--filter', packageName, 'pack', '--json', '--pack-destination', destDir], + { cwd: repoRoot, stdio: ['ignore', 'pipe', 'inherit'], encoding: 'utf8' } + ); + const packed = JSON.parse(stdout); + return { + name: packed.name, + version: packed.version, + tarballPath: packed.filename, + }; +} + +/** Runs one of the fixture's check scripts and returns its captured stdout. */ +function runFixtureScript(file, cwd) { + try { + return execFileSync(process.execPath, [file], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const output = [error.stdout, error.stderr] + .filter(Boolean) + .join('\n') + .trim(); + throw new Error(output || error.message); + } +} + +function assertMarker(output, marker, failureMessage) { + if (!output.includes(marker)) { + throw new Error(`${failureMessage}\n${output}`.trim()); + } +} + +function assertRealDirectoryInstall(consumerDir, packageName) { + const installedPath = path.join( + consumerDir, + 'node_modules', + ...packageName.split('/') + ); + const linkStat = lstatSync(installedPath); + if (linkStat.isSymbolicLink()) { + throw new Error( + `${packageName} was installed as a symlink at ${installedPath}; expected a real, ` + + 'copied directory -- workspace linkage leaked into the fixture' + ); + } + if (!statSync(installedPath).isDirectory()) { + throw new Error( + `${packageName} is not installed as a directory at ${installedPath}` + ); + } +} + +function writeFixtureProject(consumerDir, { supabase, utils }) { + mkdirSync(consumerDir, { recursive: true }); + + const packageJson = { + name: 'packed-platform-consumer-fixture', + private: true, + version: '0.0.0', + type: 'module', + description: + 'Throwaway fixture proving @supabase/mcp-server-supabase installs and runs on a ' + + 'Platform-shaped dependency graph, outside this pnpm workspace.', + dependencies: { + [SUPABASE_PACKAGE_NAME]: `file:${supabase.tarballPath}`, + [UTILS_PACKAGE_NAME]: `file:${utils.tarballPath}`, + '@modelcontextprotocol/server': SDK_VERSION, + '@modelcontextprotocol/client': SDK_VERSION, + zod: PLATFORM_ZOD_VERSION, + typescript: TYPESCRIPT_VERSION_RANGE, + }, + }; + + writeFileSync( + path.join(consumerDir, 'package.json'), + `${JSON.stringify(packageJson, null, 2)}\n` + ); + writeFileSync( + path.join(consumerDir, 'tsconfig.json'), + `${JSON.stringify(TSCONFIG, null, 2)}\n` + ); + writeFileSync(path.join(consumerDir, 'esm-check.mjs'), ESM_CHECK_SOURCE); + writeFileSync(path.join(consumerDir, 'cjs-check.cjs'), CJS_CHECK_SOURCE); + writeFileSync(path.join(consumerDir, 'types-check.ts'), TYPES_CHECK_SOURCE); + writeFileSync(path.join(consumerDir, 'modern-call.mjs'), MODERN_CALL_SOURCE); +} + +function assertEsmEntry(consumerDir) { + assertMarker( + runFixtureScript('esm-check.mjs', consumerDir), + 'ESM_OK', + 'ESM entry did not report success' + ); +} + +function assertCjsEntry(consumerDir) { + assertMarker( + runFixtureScript('cjs-check.cjs', consumerDir), + 'CJS_OK', + 'CJS entry did not report success' + ); +} + +function assertTypeDeclarations(consumerDir) { + const tscBin = path.join(consumerDir, 'node_modules', '.bin', 'tsc'); + try { + execFileSync(tscBin, ['--project', 'tsconfig.json'], { + cwd: consumerDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const output = [error.stdout, error.stderr] + .filter(Boolean) + .join('\n') + .trim(); + throw new Error(`tsc reported errors:\n${output}`); + } +} + +function assertModernCall(consumerDir) { + assertMarker( + runFixtureScript('modern-call.mjs', consumerDir), + 'MODERN_CALL_OK', + 'the modern MCP call did not complete' + ); +} + +async function main() { + console.log( + 'Building @supabase/mcp-utils and @supabase/mcp-server-supabase...' + ); + runVisible( + 'pnpm', + [ + '--filter', + UTILS_PACKAGE_NAME, + '--filter', + SUPABASE_PACKAGE_NAME, + 'build', + ], + repoRoot + ); + + // Outside the repo tree on purpose: pnpm workspace resolution (workspace:, + // catalog:, or symlinked node_modules) cannot reach in from here. + const tmpRoot = mkdtempSync( + path.join(tmpdir(), 'mcp-packed-platform-consumer-') + ); + const tarballDir = path.join(tmpRoot, 'tarballs'); + const consumerDir = path.join(tmpRoot, 'consumer'); + mkdirSync(tarballDir, { recursive: true }); + + let cleanUp = false; + try { + console.log('\nPacking workspace tarballs...'); + const utils = packWorkspacePackage(UTILS_PACKAGE_NAME, tarballDir); + const supabase = packWorkspacePackage(SUPABASE_PACKAGE_NAME, tarballDir); + console.log(`Packed ${SUPABASE_PACKAGE_NAME}@${supabase.version}`); + console.log(`Packed ${UTILS_PACKAGE_NAME}@${utils.version}`); + + console.log( + `\nWriting consumer fixture (zod pinned to ${PLATFORM_ZOD_VERSION})...` + ); + writeFixtureProject(consumerDir, { supabase, utils }); + + console.log('\nInstalling with plain npm (no pnpm, no workspace)...'); + runVisible('npm', ['install'], consumerDir); + + console.log( + '\nVerifying the install is a real copy, not a workspace symlink...' + ); + assertRealDirectoryInstall(consumerDir, SUPABASE_PACKAGE_NAME); + assertRealDirectoryInstall(consumerDir, UTILS_PACKAGE_NAME); + + console.log('\nRunning the four public-surface assertions...'); + const assertions = [ + { name: 'esm-entry', run: () => assertEsmEntry(consumerDir) }, + { name: 'cjs-entry', run: () => assertCjsEntry(consumerDir) }, + { + name: 'type-declarations', + run: () => assertTypeDeclarations(consumerDir), + }, + { name: 'modern-mcp-call', run: () => assertModernCall(consumerDir) }, + ]; + + const results = []; + for (const assertion of assertions) { + try { + assertion.run(); + results.push({ name: assertion.name, pass: true }); + console.log(` [PASS] ${assertion.name}`); + } catch (error) { + results.push({ + name: assertion.name, + pass: false, + detail: error.message, + }); + console.error(` [FAIL] ${assertion.name}: ${error.message}`); + } + } + + const failed = results.filter((r) => !r.pass); + + console.log( + `\nPacked ${SUPABASE_PACKAGE_NAME} version tested: ${supabase.version}` + ); + + if (failed.length > 0) { + console.error( + `\n${failed.length} of ${results.length} assertion(s) failed: ` + + `${failed.map((r) => r.name).join(', ')}` + ); + console.error(`Fixture left in place for inspection: ${tmpRoot}`); + process.exitCode = 1; + return; + } + + console.log(`\nAll ${results.length} assertions passed.`); + cleanUp = true; + } catch (error) { + console.error( + `\nSetup failed before assertions could run: ${error.message}` + ); + console.error(`Fixture left in place for inspection: ${tmpRoot}`); + process.exitCode = 1; + return; + } finally { + if (cleanUp) { + rmSync(tmpRoot, { recursive: true, force: true }); + } + } +} + +await main(); From e464513e735f924b31d2f03c486cee3234ba1bb5 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 11 Aug 2026 15:00:30 +0200 Subject: [PATCH 2/5] fix: report stdio serving errors through serveStdio onerror serveStdio routes transport startup and out-of-band wire errors only through options.onerror, swallowing them otherwise, so without it a startup failure was silent. The previous awaited server.connect() surfaced it through main().catch(console.error). --- .../src/transports/stdio.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/mcp-server-supabase/src/transports/stdio.ts b/packages/mcp-server-supabase/src/transports/stdio.ts index dc828d46..9729fa08 100644 --- a/packages/mcp-server-supabase/src/transports/stdio.ts +++ b/packages/mcp-server-supabase/src/transports/stdio.ts @@ -71,14 +71,19 @@ async function main() { apiUrl, }); - serveStdio(() => - createSupabaseMcpServer({ - platform, - projectId, - readOnly, - features, - contentApiUrl, - }) + // `serveStdio` reports transport startup and out-of-band wire errors only + // through `onerror`, and swallows them otherwise, so this keeps the stderr + // output the previous awaited `server.connect()` got from `main().catch`. + serveStdio( + () => + createSupabaseMcpServer({ + platform, + projectId, + readOnly, + features, + contentApiUrl, + }), + { onerror: console.error } ); } From bfae7b13749843f627bcf3562987eed4cde16bd3 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 11 Aug 2026 18:00:58 +0200 Subject: [PATCH 3/5] fix: address review findings on dual-era serving Restore eager --features validation. Moving server construction into serveStdio's lazy factory deferred parseFeatureGroups until the first valid opening message, so an invalid --features value no longer reported and terminated at startup. Validate the explicitly provided list before serving, leaving platform-dependent default resolution inside createSupabaseMcpServer. Covered by a new startup-failure test. Make the packed-consumer gate prove what it claimed. Its only schema check was a truthiness test on list_projects, a zero-argument tool whose healthy schema already carries no properties, so a zod regression emitting property-less schemas passed. Assert a parameterised witness instead, and drop skipLibCheck so the packed declarations are really type-checked. Pin the fixture's dev dependencies exactly and install without lifecycle scripts. Move the fixture's source out of JS string constants into real files, collapse the per-check pass-throughs into one runner plus a table, and drop the esm-entry check that modern-call.mjs already covers by importing and calling the package ESM entry. The script goes from 429 lines to 240. Share the msw lifecycle between the two suites that had copied it, assert status and error code rather than the SDK's exact envelope wording, guard the integration suite against a stale dist build, and document createSupabaseMcpHandler with the required per-request mounting pattern. --- README.md | 31 ++ .../mcp-server-supabase/src/server.test.ts | 20 +- .../src/transports/http.test.ts | 42 +-- .../src/transports/stdio.ts | 5 + packages/mcp-server-supabase/test/mocks.ts | 13 + .../test/stdio.integration.ts | 74 ++++- .../packed-platform-consumer/cjs-check.cjs | 9 + .../packed-platform-consumer/modern-call.mjs | 83 +++++ .../packed-platform-consumer/tsconfig.json | 13 + .../packed-platform-consumer/types-check.ts | 32 ++ scripts/test-packed-platform-consumer.mjs | 299 +++--------------- 11 files changed, 314 insertions(+), 307 deletions(-) create mode 100644 scripts/fixtures/packed-platform-consumer/cjs-check.cjs create mode 100644 scripts/fixtures/packed-platform-consumer/modern-call.mjs create mode 100644 scripts/fixtures/packed-platform-consumer/tsconfig.json create mode 100644 scripts/fixtures/packed-platform-consumer/types-check.ts diff --git a/README.md b/README.md index f2b72747..1ff941e6 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,37 @@ const tools = await mcpClient.tools({ For more information, see [Schema Definition](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools#schema-definition) and [Typed Tool Outputs](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools#typed-tool-outputs) in the AI SDK docs. +## Self-hosting the MCP endpoint + +The `@supabase/mcp-server-supabase` package exports `createSupabaseMcpHandler()` to serve the tools over HTTP from your own endpoint. It accepts the same `SupabaseMcpServerOptions` as `createSupabaseMcpServer()`, most importantly `platform`. + +The handler speaks the current protocol revision only. It is created with `legacy: 'reject'`, so a client that only speaks the 2025-era protocol receives an HTTP 400 instead of being served. + +Create the handler per request, with a `platform` bound to that request's token, and close it once the response finishes. A shared, long-lived handler is not supported, because a singleton captures the first request's user, response object, and request-scoped dependencies. + +```ts +import { createServer } from 'node:http'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createSupabaseMcpHandler } from '@supabase/mcp-server-supabase'; +import { createSupabaseApiPlatform } from '@supabase/mcp-server-supabase/platform/api'; + +const server = createServer(async (req, res) => { + const accessToken = getAccessTokenFromRequest(req); // your own auth + + const handler = createSupabaseMcpHandler({ + platform: createSupabaseApiPlatform({ accessToken }), + }); + + res.on('close', () => { + handler.close(); + }); + + await toNodeHandler(handler)(req, res); +}); +``` + +`toNodeHandler` comes from `@modelcontextprotocol/node`, which is not a dependency of this package. Install it alongside. + ## Other MCP servers ### `@supabase/mcp-server-postgrest` diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 3649def2..b411e3f0 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -4,7 +4,7 @@ import { StreamTransport } from '@supabase/mcp-utils'; import { codeBlock, stripIndent } from 'common-tags'; import gqlmin from 'gqlmin'; import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; +import type { SetupServer } from 'msw/node'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { globalRegistry } from 'zod/v4'; @@ -17,12 +17,8 @@ import { createProject, MCP_CLIENT_NAME, MCP_CLIENT_VERSION, - mockBranches, - mockContentApi, mockContentApiSchemaLoadCount, - mockManagementApi, - mockOrgs, - mockProjects, + setupMockApis, } from '../test/mocks.js'; import { createSupabaseApiPlatform } from './platform/api-platform.js'; import type { SupabasePlatform } from './platform/types.js'; @@ -33,16 +29,10 @@ import { supabaseMcpToolSchemas, } from './tools/tool-schemas.js'; -let mockServer: ReturnType | undefined; +let mockServer: SetupServer | undefined; -beforeEach(async () => { - mockOrgs.clear(); - mockProjects.clear(); - mockBranches.clear(); - mockContentApiSchemaLoadCount.value = 0; - - mockServer = setupServer(...mockContentApi, ...mockManagementApi); - mockServer.listen({ onUnhandledRequest: 'error' }); +beforeEach(() => { + mockServer = setupMockApis(); }); afterEach(() => { diff --git a/packages/mcp-server-supabase/src/transports/http.test.ts b/packages/mcp-server-supabase/src/transports/http.test.ts index 3a3f0711..a3632002 100644 --- a/packages/mcp-server-supabase/src/transports/http.test.ts +++ b/packages/mcp-server-supabase/src/transports/http.test.ts @@ -7,7 +7,7 @@ import { PROTOCOL_VERSION_META_KEY, } from '@modelcontextprotocol/server'; import { http, HttpResponse } from 'msw'; -import { setupServer, type SetupServer } from 'msw/node'; +import type { SetupServer } from 'msw/node'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { @@ -15,12 +15,7 @@ import { API_URL, MCP_CLIENT_NAME, MCP_CLIENT_VERSION, - mockBranches, - mockContentApi, - mockContentApiSchemaLoadCount, - mockManagementApi, - mockOrgs, - mockProjects, + setupMockApis, } from '../../test/mocks.js'; import { createSupabaseApiPlatform } from '../platform/api-platform.js'; import { createSupabaseMcpHandler } from './http.js'; @@ -28,17 +23,11 @@ import { createSupabaseMcpHandler } from './http.js'; const MODERN_PROTOCOL_VERSION = '2026-07-28'; const MCP_ENDPOINT = new URL('https://mcp.test'); -let mockServer: SetupServer | undefined; +let mockServer!: SetupServer; const cleanups: Array<() => Promise> = []; beforeEach(() => { - mockOrgs.clear(); - mockProjects.clear(); - mockBranches.clear(); - mockContentApiSchemaLoadCount.value = 0; - - mockServer = setupServer(...mockContentApi, ...mockManagementApi); - mockServer.listen({ onUnhandledRequest: 'error' }); + mockServer = setupMockApis(); }); afterEach(async () => { @@ -47,19 +36,18 @@ afterEach(async () => { await cleanup(); } } finally { - mockServer?.close(); + mockServer.close(); } }); -function createPlatform() { - return createSupabaseApiPlatform({ - accessToken: ACCESS_TOKEN, - apiUrl: API_URL, +function createHandler() { + const handler = createSupabaseMcpHandler({ + platform: createSupabaseApiPlatform({ + accessToken: ACCESS_TOKEN, + apiUrl: API_URL, + }), + readOnly: true, }); -} - -function createHandler(platform = createPlatform()) { - const handler = createSupabaseMcpHandler({ platform, readOnly: true }); cleanups.push(() => handler.close()); @@ -154,13 +142,11 @@ describe('createSupabaseMcpHandler', () => { ); expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ + await expect(response.json()).resolves.toMatchObject({ jsonrpc: '2.0', id: 1, error: { code: -32022, - message: - 'Unsupported protocol version: the request did not name a protocol version', data: { supported: [MODERN_PROTOCOL_VERSION] }, }, }); @@ -202,7 +188,7 @@ describe('createSupabaseMcpHandler', () => { test('close releases an in-flight request', async () => { const requestStarted = deferred(); const releaseRequest = deferred(); - mockServer?.use( + mockServer.use( http.get(`${API_URL}/v1/projects`, async () => { requestStarted.resolve(); await releaseRequest.promise; diff --git a/packages/mcp-server-supabase/src/transports/stdio.ts b/packages/mcp-server-supabase/src/transports/stdio.ts index 9729fa08..634440a4 100644 --- a/packages/mcp-server-supabase/src/transports/stdio.ts +++ b/packages/mcp-server-supabase/src/transports/stdio.ts @@ -5,6 +5,7 @@ import { serveStdio } from '@modelcontextprotocol/server/stdio'; import packageJson from '../../package.json' with { type: 'json' }; import { createSupabaseApiPlatform } from '../platform/api-platform.js'; import { createSupabaseMcpServer } from '../server.js'; +import { parseFeatureGroups } from '../util.js'; import { parseList } from './util.js'; const { version } = packageJson; @@ -71,6 +72,10 @@ async function main() { apiUrl, }); + if (features) { + parseFeatureGroups(platform, features); + } + // `serveStdio` reports transport startup and out-of-band wire errors only // through `onerror`, and swallows them otherwise, so this keeps the stderr // output the previous awaited `server.connect()` got from `main().catch`. diff --git a/packages/mcp-server-supabase/test/mocks.ts b/packages/mcp-server-supabase/test/mocks.ts index 2d58cf53..79ee03a3 100644 --- a/packages/mcp-server-supabase/test/mocks.ts +++ b/packages/mcp-server-supabase/test/mocks.ts @@ -3,6 +3,7 @@ import { source } from 'common-tags'; import { format } from 'date-fns'; import { buildSchema, parse, validate } from 'graphql'; import { http, HttpResponse } from 'msw'; +import { setupServer, type SetupServer } from 'msw/node'; import { customAlphabet } from 'nanoid'; import { join } from 'node:path/posix'; import { expect } from 'vitest'; @@ -934,6 +935,18 @@ export const mockManagementApi = [ ), ]; +export function setupMockApis(): SetupServer { + mockOrgs.clear(); + mockProjects.clear(); + mockBranches.clear(); + mockContentApiSchemaLoadCount.value = 0; + + const mockServer = setupServer(...mockContentApi, ...mockManagementApi); + mockServer.listen({ onUnhandledRequest: 'error' }); + + return mockServer; +} + export async function createOrganization(options: MockOrganizationOptions) { const org = new MockOrganization(options); mockOrgs.set(org.id, org); diff --git a/packages/mcp-server-supabase/test/stdio.integration.ts b/packages/mcp-server-supabase/test/stdio.integration.ts index 14df24f2..2aaf0bb6 100644 --- a/packages/mcp-server-supabase/test/stdio.integration.ts +++ b/packages/mcp-server-supabase/test/stdio.integration.ts @@ -1,7 +1,9 @@ import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import gqlmin from 'gqlmin'; +import { existsSync, readdirSync, statSync } from 'node:fs'; import { createServer, type Server } from 'node:http'; +import { join } from 'node:path'; import { afterEach, describe, expect, test } from 'vitest'; import { ACCESS_TOKEN, @@ -18,17 +20,45 @@ type SetupOptions = { accessToken?: string; projectId?: string; readOnly?: boolean; + features?: string; contentApiUrl?: string; apiUrl?: string; env?: Record; }; +function assertStdioBuildIsFresh() { + const buildPath = 'dist/transports/stdio.js'; + const newestSource = readdirSync('src', { + recursive: true, + withFileTypes: true, + }) + .filter((entry) => entry.isFile()) + .map((entry) => { + const path = join(entry.parentPath, entry.name); + return { path, mtimeMs: statSync(path).mtimeMs }; + }) + .reduce((newest, source) => + source.mtimeMs > newest.mtimeMs ? source : newest + ); + const buildMtimeMs = existsSync(buildPath) + ? statSync(buildPath).mtimeMs + : Number.NEGATIVE_INFINITY; + + expect( + buildMtimeMs, + `${buildPath} is missing or older than ${newestSource.path}; run \`pnpm build\`.` + ).toBeGreaterThanOrEqual(newestSource.mtimeMs); +} + +assertStdioBuildIsFresh(); + async function setup(options: SetupOptions = {}) { const { accessToken = ACCESS_TOKEN, era = 'legacy', projectId, readOnly, + features, apiUrl, contentApiUrl, env, @@ -70,6 +100,10 @@ async function setup(options: SetupOptions = {}) { args.push('--read-only'); } + if (features) { + args.push('--features', features); + } + if (apiUrl) { args.push('--api-url', apiUrl); } @@ -264,23 +298,23 @@ describe('stdio', () => { }, ]; - if (era === 'modern') { - expect(toolResult).toEqual({ - _meta: { - 'io.modelcontextprotocol/serverInfo': { - name: 'supabase', - title: 'Supabase', - version: MCP_SERVER_VERSION, - }, - }, - content: expectedToolContent, - }); - } else { - expect(toolResult).not.toHaveProperty('_meta'); - expect(toolResult).toEqual({ - content: expectedToolContent, - }); - } + const expectedMeta = + era === 'modern' + ? { + _meta: { + 'io.modelcontextprotocol/serverInfo': { + name: 'supabase', + title: 'Supabase', + version: MCP_SERVER_VERSION, + }, + }, + } + : {}; + expect(Object.hasOwn(toolResult, '_meta')).toBe(era === 'modern'); + expect(toolResult).toEqual({ + ...expectedMeta, + content: expectedToolContent, + }); expect( managementApiStub.hits.map(({ method, url }) => ({ method, @@ -314,6 +348,12 @@ describe('stdio', () => { // fixed v1 client, both the pre- and post-migration builds return the v1 string. await expect(setupPromise).rejects.toThrow('Connection closed'); }); + + test('invalid --features fails at startup', async () => { + const setupPromise = setup({ features: 'invalid' }); + + await expect(setupPromise).rejects.toThrow('Connection closed'); + }); }); describe('stdio content-api-url', () => { diff --git a/scripts/fixtures/packed-platform-consumer/cjs-check.cjs b/scripts/fixtures/packed-platform-consumer/cjs-check.cjs new file mode 100644 index 00000000..6baa7de3 --- /dev/null +++ b/scripts/fixtures/packed-platform-consumer/cjs-check.cjs @@ -0,0 +1,9 @@ +const { createSupabaseMcpHandler } = require('@supabase/mcp-server-supabase'); + +if (typeof createSupabaseMcpHandler !== 'function') { + throw new Error( + `expected createSupabaseMcpHandler to be a function, got ${typeof createSupabaseMcpHandler}` + ); +} + +console.log('CJS_OK'); diff --git a/scripts/fixtures/packed-platform-consumer/modern-call.mjs b/scripts/fixtures/packed-platform-consumer/modern-call.mjs new file mode 100644 index 00000000..f1cabde2 --- /dev/null +++ b/scripts/fixtures/packed-platform-consumer/modern-call.mjs @@ -0,0 +1,83 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { createSupabaseMcpHandler } from '@supabase/mcp-server-supabase'; + +const MODERN_PROTOCOL_VERSION = '2026-07-28'; + +// Stubbed `account` operations only run on a tool call. The account feature +// registers real zod-built schemas, and the tools/list checks below verify +// that create_project keeps its required properties with Platform's zod pin. +// `docs` stays out: its tool description lazily calls supabase.com, and this +// exchange must never leave the process. +const notImplemented = () => Promise.reject(new Error('not implemented')); + +const handler = createSupabaseMcpHandler({ + platform: { + account: { + listOrganizations: notImplemented, + getOrganization: notImplemented, + listProjects: notImplemented, + getProject: notImplemented, + createProject: notImplemented, + pauseProject: notImplemented, + restoreProject: notImplemented, + }, + }, + features: ['account'], +}); + +const transport = new StreamableHTTPClientTransport( + new URL('http://packed-platform-consumer-fixture.invalid/mcp'), + { + // Routes every request straight into the handler's fetch face in-process. + fetch: (url, init) => handler.fetch(new Request(url, init)), + } +); + +const client = new Client( + { name: 'packed-platform-consumer-fixture', version: '0.0.0' }, + { versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } } } +); + +await client.connect(transport); +const { tools } = await client.listTools(); + +// The no-argument witness proves that list_projects and its input schema are +// present. The parameterized create_project witness below proves that the +// zod-built schema keeps its required inputs across the dependency boundary. +const listProjects = tools.find((tool) => tool.name === 'list_projects'); + +if (!listProjects?.inputSchema) { + throw new Error( + `tools/list did not return list_projects with an input schema (got: ${JSON.stringify(tools.map((tool) => tool.name))})` + ); +} + +const createProject = tools.find((tool) => tool.name === 'create_project'); + +if (!createProject?.inputSchema?.properties) { + throw new Error( + `tools/list did not return create_project with input schema properties (got: ${JSON.stringify(tools.map((tool) => tool.name))})` + ); +} + +const requiredCreateProjectProperties = ['name', 'region', 'organization_id']; +const createProjectProperties = Object.keys( + createProject.inputSchema.properties +); +const missingCreateProjectProperties = requiredCreateProjectProperties.filter( + (property) => !createProjectProperties.includes(property) +); + +if (missingCreateProjectProperties.length > 0) { + throw new Error( + `create_project input schema is missing required properties: ${missingCreateProjectProperties.join(', ')} (got: ${JSON.stringify(createProjectProperties)})` + ); +} + +await client.close(); +await handler.close(); + +console.log(`MODERN_CALL_OK tools=${tools.length}`); diff --git a/scripts/fixtures/packed-platform-consumer/tsconfig.json b/scripts/fixtures/packed-platform-consumer/tsconfig.json new file mode 100644 index 00000000..d76b6d67 --- /dev/null +++ b/scripts/fixtures/packed-platform-consumer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "skipLibCheck": false, + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["types-check.ts"] +} diff --git a/scripts/fixtures/packed-platform-consumer/types-check.ts b/scripts/fixtures/packed-platform-consumer/types-check.ts new file mode 100644 index 00000000..5e64e7c3 --- /dev/null +++ b/scripts/fixtures/packed-platform-consumer/types-check.ts @@ -0,0 +1,32 @@ +import { + createSupabaseMcpHandler, + type SupabaseMcpServerOptions, +} from '@supabase/mcp-server-supabase'; + +// A stubbed `account` platform, whose seven operations only ever run on a +// tool call and so can reject here. It buys the thing that matters: asking +// for the `account` feature group makes the server register real tools, so +// the typecheck covers the zod-backed tool surface rather than an empty one. +const account: SupabaseMcpServerOptions['platform']['account'] = { + listOrganizations: () => Promise.reject(new Error('not implemented')), + getOrganization: () => Promise.reject(new Error('not implemented')), + listProjects: () => Promise.reject(new Error('not implemented')), + getProject: () => Promise.reject(new Error('not implemented')), + createProject: () => Promise.reject(new Error('not implemented')), + pauseProject: () => Promise.reject(new Error('not implemented')), + restoreProject: () => Promise.reject(new Error('not implemented')), +}; + +const options: SupabaseMcpServerOptions = { + platform: { account }, + features: ['account'], +}; + +const handler = createSupabaseMcpHandler(options); + +// Touch every member of the public McpHttpHandler shape so a signature +// change here fails the typecheck, not just a missing export. +void handler.fetch; +void handler.close; +void handler.notify; +void handler.bus; diff --git a/scripts/test-packed-platform-consumer.mjs b/scripts/test-packed-platform-consumer.mjs index edc1a561..ecda8651 100644 --- a/scripts/test-packed-platform-consumer.mjs +++ b/scripts/test-packed-platform-consumer.mjs @@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process'; import { + cpSync, lstatSync, mkdirSync, mkdtempSync, @@ -25,11 +26,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; // --------------------------------------------------------------------------- -// Platform's pinned zod version. `supabase/platform`'s `develop` catalog pins -// zod to exactly this version today (checked 2026-08-11 at commit -// 603f39cb4c). The SDK only requires `zod: ^4.2.0`, so a Platform-shaped -// consumer must install cleanly on this pin. Bump this one constant -- and -// nothing else -- when Platform's catalog pin changes. +// Platform's `develop` catalog pins zod to this exact version (checked +// 2026-08-11 at commit 603f39cb4c). The fixture installs this pin and verifies +// that create_project keeps its required schema properties. Update this +// constant when Platform's catalog pin changes. const PLATFORM_ZOD_VERSION = '4.4.3'; // --------------------------------------------------------------------------- @@ -39,147 +39,38 @@ const PLATFORM_ZOD_VERSION = '4.4.3'; const SDK_VERSION = '2.0.0'; // TypeScript used to typecheck the packed `.d.ts` surface in the fixture. -// Matches the range the workspace itself develops against. -const TYPESCRIPT_VERSION_RANGE = '^5.6.3'; - -const MODERN_PROTOCOL_VERSION = '2026-07-28'; +// Matches the minimum version the workspace itself develops against. +const TYPESCRIPT_VERSION = '5.6.3'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); +const fixtureDir = path.join(__dirname, 'fixtures', 'packed-platform-consumer'); const SUPABASE_PACKAGE_NAME = '@supabase/mcp-server-supabase'; const UTILS_PACKAGE_NAME = '@supabase/mcp-utils'; -const TSCONFIG = { - compilerOptions: { - strict: true, - target: 'ES2022', - module: 'NodeNext', - moduleResolution: 'NodeNext', - skipLibCheck: true, - noEmit: true, - esModuleInterop: true, - forceConsistentCasingInFileNames: true, +const CHECKS = [ + { + name: 'cjs-entry', + // modern-mcp-call imports and calls the package's ESM entry. + argv: [process.execPath, 'cjs-check.cjs'], + marker: 'CJS_OK', }, - include: ['types-check.ts'], -}; - -const ESM_CHECK_SOURCE = `import { createSupabaseMcpHandler } from '${SUPABASE_PACKAGE_NAME}'; - -if (typeof createSupabaseMcpHandler !== 'function') { - throw new Error( - \`expected createSupabaseMcpHandler to be a function, got \${typeof createSupabaseMcpHandler}\` - ); -} - -console.log('ESM_OK'); -`; - -const CJS_CHECK_SOURCE = `const { createSupabaseMcpHandler } = require('${SUPABASE_PACKAGE_NAME}'); - -if (typeof createSupabaseMcpHandler !== 'function') { - throw new Error( - \`expected createSupabaseMcpHandler to be a function, got \${typeof createSupabaseMcpHandler}\` - ); -} - -console.log('CJS_OK'); -`; - -const TYPES_CHECK_SOURCE = `import { - createSupabaseMcpHandler, - type SupabaseMcpServerOptions, -} from '${SUPABASE_PACKAGE_NAME}'; - -// A stubbed \`account\` platform, whose seven operations only ever run on a -// tool call and so can reject here. It buys the thing that matters: asking -// for the \`account\` feature group makes the server register real tools, so -// the typecheck covers the zod-backed tool surface rather than an empty one. -const account: SupabaseMcpServerOptions['platform']['account'] = { - listOrganizations: () => Promise.reject(new Error('not implemented')), - getOrganization: () => Promise.reject(new Error('not implemented')), - listProjects: () => Promise.reject(new Error('not implemented')), - getProject: () => Promise.reject(new Error('not implemented')), - createProject: () => Promise.reject(new Error('not implemented')), - pauseProject: () => Promise.reject(new Error('not implemented')), - restoreProject: () => Promise.reject(new Error('not implemented')), -}; - -const options: SupabaseMcpServerOptions = { - platform: { account }, - features: ['account'], -}; - -const handler = createSupabaseMcpHandler(options); - -// Touch every member of the public McpHttpHandler shape so a signature -// change here fails the typecheck, not just a missing export. -void handler.fetch; -void handler.close; -void handler.notify; -void handler.bus; -`; - -const MODERN_CALL_SOURCE = `import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { createSupabaseMcpHandler } from '${SUPABASE_PACKAGE_NAME}'; - -const MODERN_PROTOCOL_VERSION = '${MODERN_PROTOCOL_VERSION}'; - -// Stubbed \`account\` operations, which only run on a tool call, paired with -// \`features: ['account']\` so the server registers its real zod-built tool -// schemas. That is the path a zod version skew between Platform's catalog and -// the SDK's own dependency would break, so an empty catalog would not test it. -// \`docs\` stays out: its tool description lazily calls supabase.com, and this -// exchange must never leave the process. -const notImplemented = () => Promise.reject(new Error('not implemented')); - -const handler = createSupabaseMcpHandler({ - platform: { - account: { - listOrganizations: notImplemented, - getOrganization: notImplemented, - listProjects: notImplemented, - getProject: notImplemented, - createProject: notImplemented, - pauseProject: notImplemented, - restoreProject: notImplemented, - }, + { + name: 'type-declarations', + argv: [ + path.join('node_modules', '.bin', 'tsc'), + '--project', + 'tsconfig.json', + ], + marker: null, }, - features: ['account'], -}); - -const transport = new StreamableHTTPClientTransport( - new URL('http://packed-platform-consumer-fixture.invalid/mcp'), { - // Routes every request straight into the handler's fetch face in-process. - fetch: (url, init) => handler.fetch(new Request(url, init)), - } -); - -const client = new Client( - { name: 'packed-platform-consumer-fixture', version: '0.0.0' }, - { versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } } } -); - -await client.connect(transport); -const { tools } = await client.listTools(); - -// A real tool carrying a real input schema, so this proves the zod-built tool -// surface survives Platform's zod version. An empty array would pass a -// transport round trip while testing none of that. -const listProjects = tools.find((tool) => tool.name === 'list_projects'); - -if (!listProjects?.inputSchema) { - throw new Error( - \`tools/list did not return list_projects with an input schema (got: \${JSON.stringify(tools.map((tool) => tool.name))})\` - ); -} - -await client.close(); -await handler.close(); - -console.log(\`MODERN_CALL_OK tools=\${tools.length}\`); -`; + name: 'modern-mcp-call', + argv: [process.execPath, 'modern-call.mjs'], + marker: 'MODERN_CALL_OK', + }, +]; /** Runs a command, streaming its own stdout/stderr straight to the console. */ function runVisible(command, args, cwd) { @@ -201,10 +92,10 @@ function packWorkspacePackage(packageName, destDir) { }; } -/** Runs one of the fixture's check scripts and returns its captured stdout. */ -function runFixtureScript(file, cwd) { +/** Runs one fixture check and returns its captured stdout and stderr. */ +function runCaptured(command, args, cwd) { try { - return execFileSync(process.execPath, [file], { + return execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], @@ -218,12 +109,6 @@ function runFixtureScript(file, cwd) { } } -function assertMarker(output, marker, failureMessage) { - if (!output.includes(marker)) { - throw new Error(`${failureMessage}\n${output}`.trim()); - } -} - function assertRealDirectoryInstall(consumerDir, packageName) { const installedPath = path.join( consumerDir, @@ -245,7 +130,7 @@ function assertRealDirectoryInstall(consumerDir, packageName) { } function writeFixtureProject(consumerDir, { supabase, utils }) { - mkdirSync(consumerDir, { recursive: true }); + cpSync(fixtureDir, consumerDir, { recursive: true }); const packageJson = { name: 'packed-platform-consumer-fixture', @@ -261,7 +146,10 @@ function writeFixtureProject(consumerDir, { supabase, utils }) { '@modelcontextprotocol/server': SDK_VERSION, '@modelcontextprotocol/client': SDK_VERSION, zod: PLATFORM_ZOD_VERSION, - typescript: TYPESCRIPT_VERSION_RANGE, + }, + devDependencies: { + '@types/node': '22.17.2', + typescript: TYPESCRIPT_VERSION, }, }; @@ -269,58 +157,9 @@ function writeFixtureProject(consumerDir, { supabase, utils }) { path.join(consumerDir, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n` ); - writeFileSync( - path.join(consumerDir, 'tsconfig.json'), - `${JSON.stringify(TSCONFIG, null, 2)}\n` - ); - writeFileSync(path.join(consumerDir, 'esm-check.mjs'), ESM_CHECK_SOURCE); - writeFileSync(path.join(consumerDir, 'cjs-check.cjs'), CJS_CHECK_SOURCE); - writeFileSync(path.join(consumerDir, 'types-check.ts'), TYPES_CHECK_SOURCE); - writeFileSync(path.join(consumerDir, 'modern-call.mjs'), MODERN_CALL_SOURCE); -} - -function assertEsmEntry(consumerDir) { - assertMarker( - runFixtureScript('esm-check.mjs', consumerDir), - 'ESM_OK', - 'ESM entry did not report success' - ); -} - -function assertCjsEntry(consumerDir) { - assertMarker( - runFixtureScript('cjs-check.cjs', consumerDir), - 'CJS_OK', - 'CJS entry did not report success' - ); } -function assertTypeDeclarations(consumerDir) { - const tscBin = path.join(consumerDir, 'node_modules', '.bin', 'tsc'); - try { - execFileSync(tscBin, ['--project', 'tsconfig.json'], { - cwd: consumerDir, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (error) { - const output = [error.stdout, error.stderr] - .filter(Boolean) - .join('\n') - .trim(); - throw new Error(`tsc reported errors:\n${output}`); - } -} - -function assertModernCall(consumerDir) { - assertMarker( - runFixtureScript('modern-call.mjs', consumerDir), - 'MODERN_CALL_OK', - 'the modern MCP call did not complete' - ); -} - -async function main() { +function main() { console.log( 'Building @supabase/mcp-utils and @supabase/mcp-server-supabase...' ); @@ -345,7 +184,6 @@ async function main() { const consumerDir = path.join(tmpRoot, 'consumer'); mkdirSync(tarballDir, { recursive: true }); - let cleanUp = false; try { console.log('\nPacking workspace tarballs...'); const utils = packWorkspacePackage(UTILS_PACKAGE_NAME, tarballDir); @@ -358,8 +196,10 @@ async function main() { ); writeFixtureProject(consumerDir, { supabase, utils }); - console.log('\nInstalling with plain npm (no pnpm, no workspace)...'); - runVisible('npm', ['install'], consumerDir); + console.log( + '\nInstalling with plain npm (no pnpm, no workspace, no lifecycle scripts)...' + ); + runVisible('npm', ['install', '--ignore-scripts'], consumerDir); console.log( '\nVerifying the install is a real copy, not a workspace symlink...' @@ -367,63 +207,28 @@ async function main() { assertRealDirectoryInstall(consumerDir, SUPABASE_PACKAGE_NAME); assertRealDirectoryInstall(consumerDir, UTILS_PACKAGE_NAME); - console.log('\nRunning the four public-surface assertions...'); - const assertions = [ - { name: 'esm-entry', run: () => assertEsmEntry(consumerDir) }, - { name: 'cjs-entry', run: () => assertCjsEntry(consumerDir) }, - { - name: 'type-declarations', - run: () => assertTypeDeclarations(consumerDir), - }, - { name: 'modern-mcp-call', run: () => assertModernCall(consumerDir) }, - ]; + console.log(`\nRunning the ${CHECKS.length} public-surface assertions...`); + for (const { name, argv, marker } of CHECKS) { + const [command, ...args] = argv; + const output = runCaptured(command, args, consumerDir); - const results = []; - for (const assertion of assertions) { - try { - assertion.run(); - results.push({ name: assertion.name, pass: true }); - console.log(` [PASS] ${assertion.name}`); - } catch (error) { - results.push({ - name: assertion.name, - pass: false, - detail: error.message, - }); - console.error(` [FAIL] ${assertion.name}: ${error.message}`); + if (marker && !output.includes(marker)) { + throw new Error(`${name} did not report ${marker}\n${output}`.trim()); } - } - const failed = results.filter((r) => !r.pass); + console.log(` [PASS] ${name}`); + } console.log( `\nPacked ${SUPABASE_PACKAGE_NAME} version tested: ${supabase.version}` ); - - if (failed.length > 0) { - console.error( - `\n${failed.length} of ${results.length} assertion(s) failed: ` + - `${failed.map((r) => r.name).join(', ')}` - ); - console.error(`Fixture left in place for inspection: ${tmpRoot}`); - process.exitCode = 1; - return; - } - - console.log(`\nAll ${results.length} assertions passed.`); - cleanUp = true; + console.log(`\nAll ${CHECKS.length} assertions passed.`); + rmSync(tmpRoot, { recursive: true, force: true }); } catch (error) { - console.error( - `\nSetup failed before assertions could run: ${error.message}` - ); + console.error(`\n${error.message}`); console.error(`Fixture left in place for inspection: ${tmpRoot}`); process.exitCode = 1; - return; - } finally { - if (cleanUp) { - rmSync(tmpRoot, { recursive: true, force: true }); - } } } -await main(); +main(); From d877078d19f0582a6a94916044668de7e2fb6898 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Tue, 11 Aug 2026 18:15:19 +0200 Subject: [PATCH 4/5] test: assert the malformed-envelope contract, not the SDK's wording The modern validation test asserted the SDK's exact envelope message, so an upstream rewording would fail a test whose contract held. Assert the status, the JSON-RPC error code, and the structured envelope data instead. The message is upstream text this package does not own; {key, problem} is machine-readable and does not churn on rewording. --- packages/mcp-server-supabase/src/transports/http.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/mcp-server-supabase/src/transports/http.test.ts b/packages/mcp-server-supabase/src/transports/http.test.ts index a3632002..919cbc3a 100644 --- a/packages/mcp-server-supabase/src/transports/http.test.ts +++ b/packages/mcp-server-supabase/src/transports/http.test.ts @@ -169,12 +169,11 @@ describe('createSupabaseMcpHandler', () => { ); expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ + await expect(response.json()).resolves.toMatchObject({ jsonrpc: '2.0', id: 2, error: { code: -32602, - message: `Invalid _meta envelope for protocol revision 2026-07-28: ${CLIENT_CAPABILITIES_META_KEY}: missing`, data: { envelope: { key: CLIENT_CAPABILITIES_META_KEY, From ce9e31324f9a48bbf0e32b6619257fb0ddf04fcb Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Wed, 12 Aug 2026 09:36:40 +0200 Subject: [PATCH 5/5] docs: don't drop close() and handler promises in the mounting example handler.close() returns a Promise that aborts in-flight exchanges, and the example discarded it inside an event callback while also making the createServer callback async, so a cleanup or serving failure surfaced as an unhandled rejection. Attach a catch to both, and keep the close on res finishing rather than on the handler resolving, since the latter would cut streaming responses short. --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1ff941e6..b8fbeee3 100644 --- a/README.md +++ b/README.md @@ -124,18 +124,20 @@ import { toNodeHandler } from '@modelcontextprotocol/node'; import { createSupabaseMcpHandler } from '@supabase/mcp-server-supabase'; import { createSupabaseApiPlatform } from '@supabase/mcp-server-supabase/platform/api'; -const server = createServer(async (req, res) => { +const server = createServer((req, res) => { const accessToken = getAccessTokenFromRequest(req); // your own auth const handler = createSupabaseMcpHandler({ platform: createSupabaseApiPlatform({ accessToken }), }); + // `close()` aborts in-flight exchanges, so close on `res` finishing rather + // than when the handler resolves, which would cut streaming responses short. res.on('close', () => { - handler.close(); + handler.close().catch((error) => console.error(error)); }); - await toNodeHandler(handler)(req, res); + toNodeHandler(handler)(req, res).catch((error) => console.error(error)); }); ```