|
7 | 7 | * faces, the per-request era write + client-identity backfill, notification |
8 | 8 | * routing, the response-mode knob, and close() teardown of the modern leg. |
9 | 9 | */ |
10 | | -import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal'; |
| 10 | +import { |
| 11 | + CLIENT_CAPABILITIES_META_KEY, |
| 12 | + CLIENT_INFO_META_KEY, |
| 13 | + classifyInboundRequest, |
| 14 | + PROTOCOL_VERSION_META_KEY |
| 15 | +} from '@modelcontextprotocol/core-internal'; |
11 | 16 | import { describe, expect, it, vi } from 'vitest'; |
12 | 17 | import * as z from 'zod/v4'; |
13 | 18 |
|
14 | 19 | import type { McpRequestContext } from '../../src/server/createMcpHandler'; |
15 | | -import { createMcpHandler, isLegacyRequest } from '../../src/server/createMcpHandler'; |
| 20 | +import { classifyEntryRequest, createMcpHandler, isLegacyRequest } from '../../src/server/createMcpHandler'; |
16 | 21 | import { McpServer } from '../../src/server/mcp'; |
17 | 22 | import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport'; |
18 | 23 |
|
@@ -736,6 +741,130 @@ describe('createMcpHandler — user-land routing with isLegacyRequest (replaces |
736 | 741 | }); |
737 | 742 | }); |
738 | 743 |
|
| 744 | +describe('classifyEntryRequest — the Request-shaped sibling of classifyInboundRequest', () => { |
| 745 | + const legacyInitialize = { |
| 746 | + jsonrpc: '2.0', |
| 747 | + id: 'init-1', |
| 748 | + method: 'initialize', |
| 749 | + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy', version: '1.0' }, capabilities: {} } |
| 750 | + }; |
| 751 | + |
| 752 | + /** The fields a hand-wired composition would extract itself to call {@linkcode classifyInboundRequest} directly. */ |
| 753 | + function handExtractedFields(request: Request, body?: unknown): Parameters<typeof classifyInboundRequest>[0] { |
| 754 | + return { |
| 755 | + httpMethod: request.method.toUpperCase(), |
| 756 | + protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, |
| 757 | + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, |
| 758 | + mcpNameHeader: request.headers.get('mcp-name') ?? undefined, |
| 759 | + ...(body !== undefined && { body }) |
| 760 | + }; |
| 761 | + } |
| 762 | + |
| 763 | + it('classifies representative requests exactly as classifyInboundRequest classifies the hand-extracted fields', async () => { |
| 764 | + const cases: Array<{ name: string; body?: unknown; request: () => Request }> = [ |
| 765 | + { |
| 766 | + name: 'modern envelope POST', |
| 767 | + body: modernToolsCall('echo', { text: 'x' }), |
| 768 | + request: () => postRequest(modernToolsCall('echo', { text: 'x' })) |
| 769 | + }, |
| 770 | + { name: 'legacy initialize POST', body: legacyInitialize, request: () => postRequest(legacyInitialize) }, |
| 771 | + { name: 'GET session operation', request: () => new Request('http://localhost/mcp', { method: 'GET' }) }, |
| 772 | + { |
| 773 | + name: 'header-only modern (modern header, claim-less body)', |
| 774 | + body: { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} }, |
| 775 | + request: () => |
| 776 | + postRequest( |
| 777 | + { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} }, |
| 778 | + { 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'tools/list' } |
| 779 | + ) |
| 780 | + } |
| 781 | + ]; |
| 782 | + |
| 783 | + for (const { name, body, request } of cases) { |
| 784 | + const classified = await classifyEntryRequest(request()); |
| 785 | + expect(classified.step, name).toBe('classified'); |
| 786 | + if (classified.step !== 'classified') continue; |
| 787 | + expect(classified.outcome, name).toEqual(classifyInboundRequest(handExtractedFields(request(), body))); |
| 788 | + expect(classified.body, name).toEqual(body); |
| 789 | + } |
| 790 | + }); |
| 791 | + |
| 792 | + it('reports a POST with a non-JSON body as no-json-body (nothing to hand-extract), with the bytes preserved for routing', async () => { |
| 793 | + const classified = await classifyEntryRequest(postRequest('{not json')); |
| 794 | + expect(classified.step).toBe('no-json-body'); |
| 795 | + if (classified.step !== 'no-json-body') return; |
| 796 | + expect(await classified.forwardRequest.text()).toBe('{not json'); |
| 797 | + }); |
| 798 | + |
| 799 | + it('reads the input body exactly once and hands routing a still-readable forwardRequest (the entry contract)', async () => { |
| 800 | + const original = { jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }; |
| 801 | + const request = postRequest(original); |
| 802 | + const classified = await classifyEntryRequest(request); |
| 803 | + |
| 804 | + expect(classified.step).toBe('classified'); |
| 805 | + if (classified.step !== 'classified') return; |
| 806 | + // The single read consumed the input request... |
| 807 | + expect(request.bodyUsed).toBe(true); |
| 808 | + // ...and forwardRequest carries the original bytes, unread — exactly |
| 809 | + // what createMcpHandler hands its legacy leg. |
| 810 | + expect(classified.forwardRequest.bodyUsed).toBe(false); |
| 811 | + expect(await classified.forwardRequest.text()).toBe(JSON.stringify(original)); |
| 812 | + expect(classified.parsedBody).toEqual(original); |
| 813 | + |
| 814 | + // With a pre-parsed body the stream is never touched and no clone is made. |
| 815 | + const preParsed = postRequest(original); |
| 816 | + const classifiedPreParsed = await classifyEntryRequest(preParsed, original); |
| 817 | + expect(classifiedPreParsed.step).toBe('classified'); |
| 818 | + if (classifiedPreParsed.step !== 'classified') return; |
| 819 | + expect(preParsed.bodyUsed).toBe(false); |
| 820 | + expect(classifiedPreParsed.forwardRequest).toBe(preParsed); |
| 821 | + |
| 822 | + // needsForward: false skips the body-preserving clone — forwardRequest |
| 823 | + // is then the (consumed) input request, the isLegacyRequest fast path. |
| 824 | + const noForward = postRequest(original); |
| 825 | + const classifiedNoForward = await classifyEntryRequest(noForward, undefined, false); |
| 826 | + expect(classifiedNoForward.step).toBe('classified'); |
| 827 | + if (classifiedNoForward.step !== 'classified') return; |
| 828 | + expect(classifiedNoForward.forwardRequest).toBe(noForward); |
| 829 | + expect(noForward.bodyUsed).toBe(true); |
| 830 | + }); |
| 831 | + |
| 832 | + it('routes on the outcome reason — legacy initialize to a session host, everything else to the entry (the hybrid pattern)', async () => { |
| 833 | + const { factory } = testFactory(); |
| 834 | + const handler = createMcpHandler(factory, { legacy: 'reject' }); |
| 835 | + const sessionHost = vi.fn(async (request: Request) => new Response(await request.text(), { status: 299 })); |
| 836 | + |
| 837 | + const route = async (request: Request): Promise<Response> => { |
| 838 | + const classified = await classifyEntryRequest(request); |
| 839 | + if (classified.step === 'unreadable-body') { |
| 840 | + return new Response(null, { status: 400 }); |
| 841 | + } |
| 842 | + if (classified.step === 'classified' && classified.outcome.kind === 'legacy' && classified.outcome.reason === 'initialize') { |
| 843 | + return sessionHost(classified.forwardRequest); |
| 844 | + } |
| 845 | + // Hand the already-parsed body along so the entry classifies from |
| 846 | + // the value instead of reading the forwarded clone a second time. |
| 847 | + return handler.fetch( |
| 848 | + classified.forwardRequest, |
| 849 | + classified.step === 'classified' ? { parsedBody: classified.parsedBody } : undefined |
| 850 | + ); |
| 851 | + }; |
| 852 | + |
| 853 | + // The 2025 handshake reaches the session host with its bytes intact. |
| 854 | + const initResponse = await route(postRequest(legacyInitialize)); |
| 855 | + expect(initResponse.status).toBe(299); |
| 856 | + expect(await initResponse.text()).toBe(JSON.stringify(legacyInitialize)); |
| 857 | + |
| 858 | + // Modern traffic is served by the entry from the forwarded clone — |
| 859 | + // the entry's own single body read still works downstream. |
| 860 | + const modernResponse = await route(postRequest(modernToolsCall('echo', { text: 'routed' }))); |
| 861 | + expect(modernResponse.status).toBe(200); |
| 862 | + const body = (await modernResponse.json()) as { result: { content: Array<{ text: string }> } }; |
| 863 | + expect(body.result.content[0]?.text).toBe('routed'); |
| 864 | + expect(sessionHost).toHaveBeenCalledTimes(1); |
| 865 | + }); |
| 866 | +}); |
| 867 | + |
739 | 868 | describe('createMcpHandler — responseMode', () => { |
740 | 869 | it('defaults to the lazy upgrade: a handler emitting a related notification streams the exchange over SSE', async () => { |
741 | 870 | const { factory } = testFactory(); |
|
0 commit comments