Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,39 @@ 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((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`
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ."
},
Expand Down
1 change: 1 addition & 0 deletions packages/mcp-server-supabase/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
createSupabaseMcpServer,
type SupabaseMcpServerOptions,
} from './server.js';
export { createSupabaseMcpHandler } from './transports/http.js';
export {
CURRENT_FEATURE_GROUPS,
type FeatureGroup,
Expand Down
20 changes: 5 additions & 15 deletions packages/mcp-server-supabase/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand All @@ -33,16 +29,10 @@ import {
supabaseMcpToolSchemas,
} from './tools/tool-schemas.js';

let mockServer: ReturnType<typeof setupServer> | 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(() => {
Expand Down
216 changes: 216 additions & 0 deletions packages/mcp-server-supabase/src/transports/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
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';

const MODERN_PROTOCOL_VERSION = '2026-07-28';
const MCP_ENDPOINT = new URL('https://mcp.test');

let mockServer!: SetupServer;
const cleanups: Array<() => Promise<void>> = [];

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<void>((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();
}
});
});
12 changes: 12 additions & 0 deletions packages/mcp-server-supabase/src/transports/http.ts
Original file line number Diff line number Diff line change
@@ -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',
});
}
30 changes: 19 additions & 11 deletions packages/mcp-server-supabase/src/transports/stdio.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Loading
Loading