|
1 | 1 | import { request as httpRequest } from 'node:http'; |
2 | 2 | import { |
3 | 3 | Client, |
| 4 | + isInputRequiredResult, |
4 | 5 | StreamableHTTPClientTransport, |
5 | 6 | } from '@modelcontextprotocol/client'; |
| 7 | +import type { |
| 8 | + CallToolResult, |
| 9 | + InputRequiredResult, |
| 10 | +} from '@modelcontextprotocol/client'; |
6 | 11 | import { http, HttpResponse, passthrough } from 'msw'; |
7 | 12 | import type { SetupServer } from 'msw/node'; |
8 | 13 | import { afterEach, beforeEach, describe, expect, test } from 'vitest'; |
9 | 14 |
|
10 | 15 | import { |
11 | 16 | ACCESS_TOKEN, |
12 | 17 | API_URL, |
| 18 | + createOrganization, |
| 19 | + createProject, |
13 | 20 | MCP_CLIENT_NAME, |
14 | 21 | MCP_CLIENT_VERSION, |
| 22 | + mockBranches, |
15 | 23 | setupMockApis, |
16 | 24 | } from '../../test/mocks.js'; |
17 | 25 | import { |
@@ -219,4 +227,121 @@ describe('startLocalHttpEntry', () => { |
219 | 227 | releaseRequest.resolve(); |
220 | 228 | } |
221 | 229 | }); |
| 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 | + }); |
222 | 347 | }); |
0 commit comments