diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8a2ad3..9f7b3f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,19 @@ Configure your MCP client to run the local build. You may need to restart the se Optionally, configure `--api-url` to point at a different Supabase instance (defaults to `https://api.supabase.com`) +## Testing + +```bash +pnpm test # unit and integration suites for all three packages +pnpm test:coverage # mcp-server-supabase, with coverage +``` + +### Packaging gates + +`scripts/` holds checks that span more than one package and run outside the pnpm workspace. `pnpm test:packed-platform-consumer` packs `@supabase/mcp-server-supabase` together with its workspace dependency `@supabase/mcp-utils`, installs both from real tarballs with plain `npm` in a temporary project, and drives the public surface there. Workspace resolution (`workspace:`, `catalog:`, symlinked `node_modules`) cannot reach that project, which is what makes it a test of the published artifact rather than of the checkout. + +Add a script here when a check needs more than one package, or needs to run from outside the workspace. Anything scoped to a single package belongs in that package's own `test` script. + ## Releases Releases are automated via [release-please](https://github.com/googleapis/release-please). It tracks commits on `main` and opens a release PR when there are releasable changes (`fix:` or `feat:`). Merging that PR: diff --git a/README.md b/README.md index f2b7274..1901033 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,41 @@ 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. + +When `platform` carries a per-request credential, create the handler per request and close it when the response finishes. The handler closes over the `platform` you supply, so a shared one serves every request with that platform. + +A long-lived handler is fine when the `platform` is meant to be shared, a service-account token for example. Create it once and `close()` it at shutdown rather than per response, since `close()` tears down the subscription router and refuses later requests. + +```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((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().catch((error) => console.error(error)); + }); + + toNodeHandler(handler)(req, res).catch((error) => console.error(error)); +}); +``` + +`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/package.json b/package.json index 73d9efa..f9045d2 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 c685bc2..bc20ab4 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/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 3649def..b411e3f 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 new file mode 100644 index 0000000..82878b2 --- /dev/null +++ b/packages/mcp-server-supabase/src/transports/http.test.ts @@ -0,0 +1,217 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { + CLIENT_CAPABILITIES_META_KEY, + PROTOCOL_VERSION_META_KEY, +} from '@modelcontextprotocol/server'; +import { http, HttpResponse } from 'msw'; +import type { SetupServer } from 'msw/node'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { + ACCESS_TOKEN, + API_URL, + MCP_CLIENT_NAME, + MCP_CLIENT_VERSION, + setupMockApis, +} from '../../test/mocks.js'; +import { createSupabaseApiPlatform } from '../platform/api-platform.js'; +import { createSupabaseMcpHandler } from './http.js'; + +// https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ +const MODERN_PROTOCOL_VERSION = '2026-07-28'; +const MCP_ENDPOINT = new URL('https://mcp.test'); + +let mockServer!: SetupServer; +const cleanups: Array<() => Promise> = []; + +beforeEach(() => { + mockServer = setupMockApis(); +}); + +afterEach(async () => { + try { + for (const cleanup of cleanups.splice(0).reverse()) { + await cleanup(); + } + } finally { + mockServer.close(); + } +}); + +function createHandler() { + const handler = createSupabaseMcpHandler({ + platform: createSupabaseApiPlatform({ + accessToken: ACCESS_TOKEN, + apiUrl: API_URL, + }), + 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.toMatchObject({ + jsonrpc: '2.0', + id: 1, + error: { + code: -32022, + 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.toMatchObject({ + jsonrpc: '2.0', + id: 2, + error: { + code: -32602, + 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 0000000..f7e8546 --- /dev/null +++ b/packages/mcp-server-supabase/src/transports/http.ts @@ -0,0 +1,14 @@ +import { createMcpHandler } from '@modelcontextprotocol/server'; + +import { + createSupabaseMcpServer, + type SupabaseMcpServerOptions, +} from '../server.js'; + +// Modern protocol only: created with `legacy: 'reject'`, so a client that +// speaks just the 2025-era protocol gets an HTTP 400 instead of being served. +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 ff5420f..634440a 100644 --- a/packages/mcp-server-supabase/src/transports/stdio.ts +++ b/packages/mcp-server-supabase/src/transports/stdio.ts @@ -1,10 +1,11 @@ #!/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'; import { createSupabaseMcpServer } from '../server.js'; +import { parseFeatureGroups } from '../util.js'; import { parseList } from './util.js'; const { version } = packageJson; @@ -71,17 +72,24 @@ async function main() { apiUrl, }); - const server = createSupabaseMcpServer({ - platform, - projectId, - readOnly, - features, - contentApiUrl, - }); - - const transport = new StdioServerTransport(); + if (features) { + parseFeatureGroups(platform, features); + } - await server.connect(transport); + // `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 } + ); } main().catch(console.error); diff --git a/packages/mcp-server-supabase/test/mocks.ts b/packages/mcp-server-supabase/test/mocks.ts index 2d58cf5..79ee03a 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 871af50..2aaf0bb 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, @@ -11,20 +13,52 @@ import { MCP_SERVER_VERSION, } from './mocks.js'; +type ProtocolEra = 'legacy' | 'modern'; + type SetupOptions = { + era?: ProtocolEra; 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, @@ -37,6 +71,8 @@ async function setup(options: SetupOptions = {}) { }, { capabilities: {}, + versionNegotiation: + era === 'modern' ? { mode: { pin: '2026-07-28' } } : { mode: 'legacy' }, } ); @@ -64,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); } @@ -175,7 +215,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 +223,7 @@ describe('stdio', () => { const { client } = await setup({ apiUrl: managementApiStub.url, contentApiUrl: contentApiStub.url, + era, }); try { @@ -231,32 +272,48 @@ 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', }, - ], - }), - }, - ], + }, + ], + }), + }, + ]; + + 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 }) => ({ @@ -275,7 +332,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 }); @@ -286,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 0000000..6baa7de --- /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 0000000..85cff15 --- /dev/null +++ b/scripts/fixtures/packed-platform-consumer/modern-call.mjs @@ -0,0 +1,84 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { createSupabaseMcpHandler } from '@supabase/mcp-server-supabase'; + +// https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ +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 0000000..d76b6d6 --- /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 0000000..5e64e7c --- /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 new file mode 100644 index 0000000..ecda865 --- /dev/null +++ b/scripts/test-packed-platform-consumer.mjs @@ -0,0 +1,234 @@ +// 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 { + cpSync, + 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 `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'; +// --------------------------------------------------------------------------- + +// 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 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 CHECKS = [ + { + name: 'cjs-entry', + // modern-mcp-call imports and calls the package's ESM entry. + argv: [process.execPath, 'cjs-check.cjs'], + marker: 'CJS_OK', + }, + { + name: 'type-declarations', + argv: [ + path.join('node_modules', '.bin', 'tsc'), + '--project', + 'tsconfig.json', + ], + marker: null, + }, + { + 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) { + 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 fixture check and returns its captured stdout and stderr. */ +function runCaptured(command, args, cwd) { + try { + return execFileSync(command, args, { + 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 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 }) { + cpSync(fixtureDir, 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, + }, + devDependencies: { + '@types/node': '22.17.2', + typescript: TYPESCRIPT_VERSION, + }, + }; + + writeFileSync( + path.join(consumerDir, 'package.json'), + `${JSON.stringify(packageJson, null, 2)}\n` + ); +} + +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 }); + + 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, no lifecycle scripts)...' + ); + runVisible('npm', ['install', '--ignore-scripts'], 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 ${CHECKS.length} public-surface assertions...`); + for (const { name, argv, marker } of CHECKS) { + const [command, ...args] = argv; + const output = runCaptured(command, args, consumerDir); + + if (marker && !output.includes(marker)) { + throw new Error(`${name} did not report ${marker}\n${output}`.trim()); + } + + console.log(` [PASS] ${name}`); + } + + console.log( + `\nPacked ${SUPABASE_PACKAGE_NAME} version tested: ${supabase.version}` + ); + console.log(`\nAll ${CHECKS.length} assertions passed.`); + rmSync(tmpRoot, { recursive: true, force: true }); + } catch (error) { + console.error(`\n${error.message}`); + console.error(`Fixture left in place for inspection: ${tmpRoot}`); + process.exitCode = 1; + } +} + +main();