Skip to content

Commit 989eb4e

Browse files
authored
feat: add dual-era package serving entries (#358)
> **This is a stacked PR. Its parent is #327, please review that one first.** > > The diff here is only this branch's own commits. The base is `raulb/spike-mcp-v2` rather than `main` on purpose, because #327 is still in review; once it lands, this retargets to `main` and the diff against `main` shrinks accordingly. > > The `test` check runs on this page and passes at `851f01e`. An earlier version of this description said it would be absent; that note is out of date. The test output under Verification came from a local run that predates the check. > > At merge time: retarget to `main`, let `tests.yml` run green there, then merge. ## What kind of change does this PR introduce? The CLI serves both protocol eras from one tool definition, and the package exports one modern HTTP handler for the hosted `/mcp` endpoint to mount. PR 2 of 3 for [AI-1044](https://linear.app/supabase/issue/AI-1044/migrate-mcp-server-supabase-to-mcp-sdk-v2) ([plan](https://linear.app/supabase/document/mcp-sdk-v2-migration-plan-d19bb064d673)). ## What is the current behavior? `src/transports/stdio.ts` connects a raw `StdioServerTransport`, so only 2025-era clients are served, and there's no HTTP entry for hosted to consume. ## What is the new behavior? `serveStdio` owns the transport and picks the era per connection. `createSupabaseMcpHandler` wraps the SDK's `createMcpHandler` with `legacy: 'reject'`, so hosted keeps authentication, ABAC, era dispatch, and logging. `tools/list` is unchanged, so the frozen ChatGPT contract is unaffected. `mcp-server-postgrest` keeps its single-era entry, and nothing here adds Elicitation machinery, tool policies, or telemetry. ## Verification - `mcp-server-supabase` unit + integration: **222 passed**. Both eras run against the real built `dist/transports/stdio.js`, spawned as a child process. - `mcp-utils`: **12 passed**. `pnpm build` and `pnpm format:check` clean. - `pnpm test:packed-platform-consumer` (new): **3/3** on packed `0.10.0`. Installs the tarballs with plain `npm` outside the repo on Platform's exact zod pin, then checks the CJS entry, a `tsc` typecheck of the packed declarations, and one modern call that imports the ESM entry. - CLI behavior unchanged: `--version` exits 0, a missing access token exits 1.
1 parent ead56f2 commit 989eb4e

15 files changed

Lines changed: 785 additions & 53 deletions

File tree

CONTRIBUTING.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ Configure your MCP client to run the local build. You may need to restart the se
4848

4949
Optionally, configure `--api-url` to point at a different Supabase instance (defaults to `https://api.supabase.com`)
5050

51+
## Testing
52+
53+
```bash
54+
pnpm test # unit and integration suites for all three packages
55+
pnpm test:coverage # mcp-server-supabase, with coverage
56+
```
57+
58+
### Packaging gates
59+
60+
`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.
61+
62+
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.
63+
5164
## Releases
5265

5366
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:

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,41 @@ const tools = await mcpClient.tools({
110110
111111
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.
112112

113+
## Self-hosting the MCP endpoint
114+
115+
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`.
116+
117+
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.
118+
119+
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.
120+
121+
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.
122+
123+
```ts
124+
import { createServer } from 'node:http';
125+
import { toNodeHandler } from '@modelcontextprotocol/node';
126+
import { createSupabaseMcpHandler } from '@supabase/mcp-server-supabase';
127+
import { createSupabaseApiPlatform } from '@supabase/mcp-server-supabase/platform/api';
128+
129+
const server = createServer((req, res) => {
130+
const accessToken = getAccessTokenFromRequest(req); // your own auth
131+
132+
const handler = createSupabaseMcpHandler({
133+
platform: createSupabaseApiPlatform({ accessToken }),
134+
});
135+
136+
// `close()` aborts in-flight exchanges, so close on `res` finishing rather
137+
// than when the handler resolves, which would cut streaming responses short.
138+
res.on('close', () => {
139+
handler.close().catch((error) => console.error(error));
140+
});
141+
142+
toNodeHandler(handler)(req, res).catch((error) => console.error(error));
143+
});
144+
```
145+
146+
`toNodeHandler` comes from `@modelcontextprotocol/node`, which is not a dependency of this package. Install it alongside.
147+
113148
## Other MCP servers
114149

115150
### `@supabase/mcp-server-postgrest`

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"build": "pnpm --filter @supabase/mcp-utils --filter @supabase/mcp-server-supabase --filter @supabase/mcp-server-postgrest build",
44
"test": "pnpm --parallel --filter @supabase/mcp-utils --filter @supabase/mcp-server-supabase --filter @supabase/mcp-server-postgrest test",
55
"test:coverage": "pnpm --filter @supabase/mcp-server-supabase test:coverage",
6+
"test:packed-platform-consumer": "node scripts/test-packed-platform-consumer.mjs",
67
"format": "biome check --write .",
78
"format:check": "biome check ."
89
},

packages/mcp-server-supabase/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
createSupabaseMcpServer,
77
type SupabaseMcpServerOptions,
88
} from './server.js';
9+
export { createSupabaseMcpHandler } from './transports/http.js';
910
export {
1011
CURRENT_FEATURE_GROUPS,
1112
type FeatureGroup,

packages/mcp-server-supabase/src/server.test.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { StreamTransport } from '@supabase/mcp-utils';
44
import { codeBlock, stripIndent } from 'common-tags';
55
import gqlmin from 'gqlmin';
66
import { http, HttpResponse } from 'msw';
7-
import { setupServer } from 'msw/node';
7+
import type { SetupServer } from 'msw/node';
88
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
99
import { globalRegistry } from 'zod/v4';
1010

@@ -17,12 +17,8 @@ import {
1717
createProject,
1818
MCP_CLIENT_NAME,
1919
MCP_CLIENT_VERSION,
20-
mockBranches,
21-
mockContentApi,
2220
mockContentApiSchemaLoadCount,
23-
mockManagementApi,
24-
mockOrgs,
25-
mockProjects,
21+
setupMockApis,
2622
} from '../test/mocks.js';
2723
import { createSupabaseApiPlatform } from './platform/api-platform.js';
2824
import type { SupabasePlatform } from './platform/types.js';
@@ -33,16 +29,10 @@ import {
3329
supabaseMcpToolSchemas,
3430
} from './tools/tool-schemas.js';
3531

36-
let mockServer: ReturnType<typeof setupServer> | undefined;
32+
let mockServer: SetupServer | undefined;
3733

38-
beforeEach(async () => {
39-
mockOrgs.clear();
40-
mockProjects.clear();
41-
mockBranches.clear();
42-
mockContentApiSchemaLoadCount.value = 0;
43-
44-
mockServer = setupServer(...mockContentApi, ...mockManagementApi);
45-
mockServer.listen({ onUnhandledRequest: 'error' });
34+
beforeEach(() => {
35+
mockServer = setupMockApis();
4636
});
4737

4838
afterEach(() => {
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import {
2+
Client,
3+
StreamableHTTPClientTransport,
4+
} from '@modelcontextprotocol/client';
5+
import {
6+
CLIENT_CAPABILITIES_META_KEY,
7+
PROTOCOL_VERSION_META_KEY,
8+
} from '@modelcontextprotocol/server';
9+
import { http, HttpResponse } from 'msw';
10+
import type { SetupServer } from 'msw/node';
11+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
12+
13+
import {
14+
ACCESS_TOKEN,
15+
API_URL,
16+
MCP_CLIENT_NAME,
17+
MCP_CLIENT_VERSION,
18+
setupMockApis,
19+
} from '../../test/mocks.js';
20+
import { createSupabaseApiPlatform } from '../platform/api-platform.js';
21+
import { createSupabaseMcpHandler } from './http.js';
22+
23+
// https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
24+
const MODERN_PROTOCOL_VERSION = '2026-07-28';
25+
const MCP_ENDPOINT = new URL('https://mcp.test');
26+
27+
let mockServer!: SetupServer;
28+
const cleanups: Array<() => Promise<void>> = [];
29+
30+
beforeEach(() => {
31+
mockServer = setupMockApis();
32+
});
33+
34+
afterEach(async () => {
35+
try {
36+
for (const cleanup of cleanups.splice(0).reverse()) {
37+
await cleanup();
38+
}
39+
} finally {
40+
mockServer.close();
41+
}
42+
});
43+
44+
function createHandler() {
45+
const handler = createSupabaseMcpHandler({
46+
platform: createSupabaseApiPlatform({
47+
accessToken: ACCESS_TOKEN,
48+
apiUrl: API_URL,
49+
}),
50+
readOnly: true,
51+
});
52+
53+
cleanups.push(() => handler.close());
54+
55+
return handler;
56+
}
57+
58+
async function setupModernClient() {
59+
const handler = createHandler();
60+
const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, {
61+
fetch: (url, init) => handler.fetch(new Request(url, init)),
62+
});
63+
const client = new Client(
64+
{
65+
name: MCP_CLIENT_NAME,
66+
version: MCP_CLIENT_VERSION,
67+
},
68+
{
69+
capabilities: {},
70+
versionNegotiation: {
71+
mode: { pin: MODERN_PROTOCOL_VERSION },
72+
},
73+
}
74+
);
75+
76+
await client.connect(transport);
77+
cleanups.push(() => client.close());
78+
79+
return { client, handler };
80+
}
81+
82+
function jsonRequest(body: unknown) {
83+
return new Request(MCP_ENDPOINT, {
84+
method: 'POST',
85+
headers: { 'content-type': 'application/json' },
86+
body: JSON.stringify(body),
87+
});
88+
}
89+
90+
function deferred() {
91+
let resolve!: () => void;
92+
const promise = new Promise<void>((resolvePromise) => {
93+
resolve = resolvePromise;
94+
});
95+
96+
return { promise, resolve };
97+
}
98+
99+
describe('createSupabaseMcpHandler', () => {
100+
test('serves discovery and tools/list to a client pinned to 2026-07-28', async () => {
101+
const { client } = await setupModernClient();
102+
103+
const { tools } = await client.listTools();
104+
105+
expect(client.getProtocolEra()).toBe('modern');
106+
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_PROTOCOL_VERSION);
107+
expect(client.getDiscoverResult()?.supportedVersions).toContain(
108+
MODERN_PROTOCOL_VERSION
109+
);
110+
expect(tools.map((tool) => tool.name)).toContain('list_projects');
111+
});
112+
113+
test('calls the same registered read-only business tool', async () => {
114+
const { client } = await setupModernClient();
115+
116+
const result = await client.callTool({
117+
name: 'search_docs',
118+
arguments: {
119+
graphql_query:
120+
'{ searchDocs(query: "typescript") { nodes { title href } } }',
121+
},
122+
});
123+
124+
expect(result.isError).not.toBe(true);
125+
expect(result.content).toEqual([
126+
{
127+
type: 'text',
128+
text: JSON.stringify({ result: { dummy: true } }),
129+
},
130+
]);
131+
});
132+
133+
test('rejects a claim-less legacy request', async () => {
134+
const handler = createHandler();
135+
136+
const response = await handler.fetch(
137+
jsonRequest({
138+
jsonrpc: '2.0',
139+
id: 1,
140+
method: 'tools/list',
141+
params: {},
142+
})
143+
);
144+
145+
expect(response.status).toBe(400);
146+
await expect(response.json()).resolves.toMatchObject({
147+
jsonrpc: '2.0',
148+
id: 1,
149+
error: {
150+
code: -32022,
151+
data: { supported: [MODERN_PROTOCOL_VERSION] },
152+
},
153+
});
154+
});
155+
156+
test('returns a modern validation error for a malformed claimed envelope', async () => {
157+
const handler = createHandler();
158+
159+
const response = await handler.fetch(
160+
jsonRequest({
161+
jsonrpc: '2.0',
162+
id: 2,
163+
method: 'tools/list',
164+
params: {
165+
_meta: {
166+
[PROTOCOL_VERSION_META_KEY]: MODERN_PROTOCOL_VERSION,
167+
},
168+
},
169+
})
170+
);
171+
172+
expect(response.status).toBe(400);
173+
await expect(response.json()).resolves.toMatchObject({
174+
jsonrpc: '2.0',
175+
id: 2,
176+
error: {
177+
code: -32602,
178+
data: {
179+
envelope: {
180+
key: CLIENT_CAPABILITIES_META_KEY,
181+
problem: 'missing',
182+
},
183+
},
184+
},
185+
});
186+
});
187+
188+
test('close releases an in-flight request', async () => {
189+
const requestStarted = deferred();
190+
const releaseRequest = deferred();
191+
mockServer.use(
192+
http.get(`${API_URL}/v1/projects`, async () => {
193+
requestStarted.resolve();
194+
await releaseRequest.promise;
195+
return HttpResponse.json([]);
196+
})
197+
);
198+
const { client, handler } = await setupModernClient();
199+
const callOutcome = client
200+
.callTool({ name: 'list_projects', arguments: {} })
201+
.then(
202+
() => ({ status: 'resolved' as const }),
203+
(error: unknown) => ({ status: 'rejected' as const, error })
204+
);
205+
206+
try {
207+
await requestStarted.promise;
208+
await handler.close();
209+
210+
await expect(callOutcome).resolves.toMatchObject({
211+
status: 'rejected',
212+
});
213+
} finally {
214+
releaseRequest.resolve();
215+
}
216+
});
217+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createMcpHandler } from '@modelcontextprotocol/server';
2+
3+
import {
4+
createSupabaseMcpServer,
5+
type SupabaseMcpServerOptions,
6+
} from '../server.js';
7+
8+
// Modern protocol only: created with `legacy: 'reject'`, so a client that
9+
// speaks just the 2025-era protocol gets an HTTP 400 instead of being served.
10+
export function createSupabaseMcpHandler(options: SupabaseMcpServerOptions) {
11+
return createMcpHandler(() => createSupabaseMcpServer(options), {
12+
legacy: 'reject',
13+
});
14+
}

0 commit comments

Comments
 (0)