Skip to content

Commit 9ff5239

Browse files
authored
feat: wire cost-confirmation elicitations into the --http entry
1 parent a910818 commit 9ff5239

3 files changed

Lines changed: 146 additions & 3 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ It binds `127.0.0.1` only and prints a ready-to-paste `.mcp.json` snippet:
4646
}
4747
```
4848

49-
The access token comes from the client's `Authorization` header on each request (Claude Code expands `${SUPABASE_ACCESS_TOKEN}` at connect time). There is no token flag and no token environment variable on the server side. Legacy clients work against this entry but do not see elicitations. You may need to restart the server in your MCP client after each change.
49+
The access token comes from the client's `Authorization` header on each request (Claude Code expands `${SUPABASE_ACCESS_TOKEN}` at connect time). There is no token flag and no token environment variable on the server side. Modern form-capable clients (Claude Code with v2 negotiation) see cost confirmations for `create_project` and `create_branch` as elicitation dialogs; legacy clients keep the `get_cost` / `confirm_cost` flow. You may need to restart the server in your MCP client after each change.
5050

5151
The entry takes a PAT in the Authorization header. OAuth is deferred: it needs resource-server and authorization-server wiring that this PR does not contain, and a 401 challenge would send Claude Code into OAuth discovery against localhost (see the PR for details).
5252

packages/mcp-server-supabase/src/transports/local-http-entry.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
import { request as httpRequest } from 'node:http';
22
import {
33
Client,
4+
isInputRequiredResult,
45
StreamableHTTPClientTransport,
56
} from '@modelcontextprotocol/client';
7+
import type {
8+
CallToolResult,
9+
InputRequiredResult,
10+
} from '@modelcontextprotocol/client';
611
import { http, HttpResponse, passthrough } from 'msw';
712
import type { SetupServer } from 'msw/node';
813
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
914

1015
import {
1116
ACCESS_TOKEN,
1217
API_URL,
18+
createOrganization,
19+
createProject,
1320
MCP_CLIENT_NAME,
1421
MCP_CLIENT_VERSION,
22+
mockBranches,
1523
setupMockApis,
1624
} from '../../test/mocks.js';
1725
import {
@@ -219,4 +227,121 @@ describe('startLocalHttpEntry', () => {
219227
releaseRequest.resolve();
220228
}
221229
});
230+
231+
describe('cost confirmation', () => {
232+
let writable!: LocalHttpEntry;
233+
let writableLogLines!: string[];
234+
235+
beforeEach(async () => {
236+
writableLogLines = [];
237+
writable = await startLocalHttpEntry({
238+
port: 0,
239+
apiUrl: API_URL,
240+
features: ['account', 'branching'],
241+
log: (line) => writableLogLines.push(line),
242+
});
243+
mockServer.use(
244+
http.all(`${new URL(writable.url).origin}/*`, () => passthrough())
245+
);
246+
cleanups.push(() => writable.close());
247+
});
248+
249+
async function connectWritable(
250+
mode: 'legacy' | { pin: string },
251+
capabilities: ConstructorParameters<typeof Client>[1] = {
252+
capabilities: {},
253+
}
254+
) {
255+
const transport = new StreamableHTTPClientTransport(
256+
new URL(writable.url),
257+
{ requestInit: { headers: AUTH_HEADERS } }
258+
);
259+
const client = new Client(
260+
{ name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION },
261+
{ ...capabilities, versionNegotiation: { mode } }
262+
);
263+
await client.connect(transport);
264+
cleanups.push(() => client.close());
265+
return client;
266+
}
267+
268+
async function createBranchingProject() {
269+
const org = await createOrganization({
270+
name: 'My Org',
271+
plan: 'free',
272+
allowed_release_channels: ['ga'],
273+
});
274+
const project = await createProject({
275+
name: 'Project 1',
276+
region: 'us-east-1',
277+
organization_id: org.id,
278+
});
279+
project.status = 'ACTIVE_HEALTHY';
280+
return project;
281+
}
282+
283+
test('a modern form-capable client receives a create_branch cost elicitation', async () => {
284+
const client = await connectWritable(
285+
{ pin: MODERN_PROTOCOL_VERSION },
286+
{
287+
capabilities: { elicitation: { form: {} } },
288+
inputRequired: { autoFulfill: false },
289+
}
290+
);
291+
const project = await createBranchingProject();
292+
293+
const result = (await client.request(
294+
{
295+
method: 'tools/call',
296+
params: {
297+
name: 'create_branch',
298+
arguments: { project_id: project.id, name: 'feature' },
299+
},
300+
},
301+
{ allowInputRequired: true }
302+
)) as CallToolResult | InputRequiredResult;
303+
304+
if (!isInputRequiredResult(result)) {
305+
throw new Error('expected an input_required result');
306+
}
307+
expect(result.inputRequests?.confirm_cost).toMatchObject({
308+
method: 'elicitation/create',
309+
params: { mode: 'form' },
310+
});
311+
expect(result.requestState).toBeTruthy();
312+
expect(mockBranches.size).toBe(0);
313+
expect(writableLogLines.at(-1)).toBe(
314+
`[mcp-http] modern ${MODERN_PROTOCOL_VERSION} client=${MCP_CLIENT_NAME}/${MCP_CLIENT_VERSION}`
315+
);
316+
});
317+
318+
test('a legacy client keeps the get_cost / confirm_cost flow', async () => {
319+
const client = await connectWritable('legacy');
320+
const project = await createBranchingProject();
321+
322+
const result = await client.callTool({
323+
name: 'create_branch',
324+
arguments: { project_id: project.id, name: 'feature' },
325+
});
326+
327+
expect(result.isError).toBe(true);
328+
expect(result.content).toEqual([
329+
{
330+
type: 'text',
331+
text: JSON.stringify({
332+
error: {
333+
name: 'Error',
334+
message:
335+
'Cost confirmation ID does not match the expected cost of creating a branch.',
336+
},
337+
}),
338+
},
339+
]);
340+
expect(mockBranches.size).toBe(0);
341+
// Stateless legacy serving only sees the client name on `initialize`.
342+
expect(writableLogLines.at(-1)).toMatch(
343+
/^\[mcp-http\] legacy client=.* \(elicitations unavailable on the legacy path\)$/
344+
);
345+
});
346+
});
222347
});

packages/mcp-server-supabase/src/transports/local-http-entry.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash, randomBytes } from 'node:crypto';
12
import { createServer } from 'node:http';
23
import type { AddressInfo } from 'node:net';
34
import {
@@ -99,6 +100,9 @@ export async function startLocalHttpEntry(
99100
log = console.error,
100101
} = options;
101102
const allowedHostnames = localhostAllowedHostnames();
103+
// Signs the cost-confirmation request state for this process; a restart
104+
// invalidates in-flight elicitations, which is the intended scope.
105+
const requestStateKey = randomBytes(32);
102106

103107
const server = createServer(
104108
toNodeListener(async (request, parsedBody) => {
@@ -127,7 +131,21 @@ export async function startLocalHttpEntry(
127131
parseFeatureGroups(platform, features);
128132
}
129133
const handler = createSupabaseMcpHandler(
130-
{ platform, projectId, readOnly, features, contentApiUrl },
134+
{
135+
platform,
136+
projectId,
137+
readOnly,
138+
features,
139+
contentApiUrl,
140+
costConfirmation: {
141+
requestStateKey,
142+
// PAT mode can serve several clients with different PATs in one
143+
// process, so the principal is the bearer's hash.
144+
// #404 (--oauth) switches this to a per-process principal; the token refreshes, the principal must not.
145+
principal: createHash('sha256').update(accessToken).digest('hex'),
146+
enabledTools: ['create_project', 'create_branch'],
147+
},
148+
},
131149
{ legacy: 'stateless', onerror: console.error }
132150
);
133151
request.signal.addEventListener('abort', () => handler.close(), {
@@ -171,6 +189,6 @@ export function formatBanner(url: string) {
171189
' }',
172190
' }',
173191
'}',
174-
'Legacy (2025-era) clients are served without elicitations. Requests log their era on stderr.',
192+
'Modern form-capable clients see cost confirmations as elicitations; legacy (2025-era) clients keep get_cost / confirm_cost. Requests log their era on stderr.',
175193
].join('\n');
176194
}

0 commit comments

Comments
 (0)