diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index 6b70fbe942..aacdf7995b 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -12,6 +12,7 @@ import { extractWWWAuthenticateParams, auth, type OAuthClientProvider, + parseErrorResponse, selectClientAuthMethod, isHttpsUrl } from '../../src/client/auth.js'; @@ -207,6 +208,20 @@ describe('OAuth Authorization', () => { await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow('HTTP 500'); }); + it('includes the discovery-failure substrings consumers classify on', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + // Auth-failure classifiers recognize discovery failures by the substrings + // 'trying to load' and 'metadata', so both are pinned message ABI. + const error = await discoverOAuthProtectedResourceMetadata('https://resource.example.com').catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('trying to load'); + expect((error as Error).message).toContain('metadata'); + }); + it('validates metadata schema', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -687,6 +702,17 @@ describe('OAuth Authorization', () => { await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('HTTP 500'); }); + it('includes the discovery-failure substrings consumers classify on', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 })); + + // Auth-failure classifiers recognize discovery failures by the substrings + // 'trying to load' and 'metadata', so both are pinned message ABI. + const error = await discoverOAuthMetadata('https://auth.example.com').catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('trying to load'); + expect((error as Error).message).toContain('metadata'); + }); + it('validates metadata schema', async () => { mockFetch.mockResolvedValueOnce( Response.json( @@ -848,6 +874,20 @@ describe('OAuth Authorization', () => { await expect(discoverAuthorizationServerMetadata('https://mcp.example.com')).rejects.toThrow('HTTP 500'); }); + it('includes the discovery-failure substrings consumers classify on', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + // Auth-failure classifiers recognize discovery failures by the substrings + // 'trying to load' and 'metadata', so both are pinned message ABI. + const error = await discoverAuthorizationServerMetadata('https://mcp.example.com').catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('trying to load'); + expect((error as Error).message).toContain('metadata'); + }); + it('handles CORS errors with retry', async () => { // First call fails with CORS mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error'))); @@ -1435,7 +1475,7 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, redirectUrl: 'http://localhost:3000/callback' }) - ).rejects.toThrow(/does not support response type/); + ).rejects.toThrow(/^Incompatible auth server: does not support response type/); }); // https://github.com/modelcontextprotocol/typescript-sdk/issues/832 @@ -1473,7 +1513,7 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, redirectUrl: 'http://localhost:3000/callback' }) - ).rejects.toThrow(/does not support code challenge method/); + ).rejects.toThrow(/^Incompatible auth server: does not support code challenge method/); } ); }); @@ -1969,7 +2009,7 @@ describe('OAuth Authorization', () => { metadata, clientMetadata: validClientMetadata }) - ).rejects.toThrow(/does not support dynamic client registration/); + ).rejects.toThrow(/^Incompatible auth server: does not support dynamic client registration/); }); it('throws on error response', async () => { @@ -1985,6 +2025,25 @@ describe('OAuth Authorization', () => { }); }); + describe('parseErrorResponse', () => { + // When a 401/403 response body is not a valid OAuth error document, the fallback + // ServerError message starts with 'HTTP : '. Consumers match /^HTTP 40[13]\b/ + // on that message to classify auth failures, so the head format is pinned ABI. + it('prefixes the fallback message with the HTTP status for non-OAuth 401 bodies', async () => { + const error = await parseErrorResponse(new Response('Unauthorized', { status: 401 })); + + expect(error).toBeInstanceOf(ServerError); + expect(error.message).toMatch(/^HTTP 401\b/); + }); + + it('prefixes the fallback message with the HTTP status for non-OAuth 403 bodies', async () => { + const error = await parseErrorResponse(new Response('Forbidden', { status: 403 })); + + expect(error).toBeInstanceOf(ServerError); + expect(error.message).toMatch(/^HTTP 403\b/); + }); + }); + describe('auth function', () => { const mockProvider: OAuthClientProvider = { get redirectUrl() { diff --git a/test/client/streamableHttp.test.ts b/test/client/streamableHttp.test.ts index 52c8f10748..aff915480f 100644 --- a/test/client/streamableHttp.test.ts +++ b/test/client/streamableHttp.test.ts @@ -1,4 +1,9 @@ -import { StartSSEOptions, StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js'; +import { + StartSSEOptions, + StreamableHTTPClientTransport, + StreamableHTTPError, + StreamableHTTPReconnectionOptions +} from '../../src/client/streamableHttp.js'; import { OAuthClientProvider, UnauthorizedError } from '../../src/client/auth.js'; import { JSONRPCMessage, JSONRPCRequest } from '../../src/types.js'; import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../../src/server/auth/errors.js'; @@ -274,6 +279,23 @@ describe('StreamableHTTPClientTransport', () => { expect(global.fetch).toHaveBeenCalledTimes(2); }); + it('should reject a failed GET connection with the Failed to open SSE stream marker', async () => { + // A 404 on the GET stream must surface as a StreamableHTTPError whose message carries + // the 'Failed to open SSE stream' marker: consumers use exactly that pair to tell a + // missing SSE endpoint apart from an expired session (which is also a 404). + (global.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found' + }); + + await transport.start(); + const error = await transport['_startOrAuthSse']({}).catch((e: unknown) => e); + expect(error).toBeInstanceOf(StreamableHTTPError); + expect((error as StreamableHTTPError).code).toBe(404); + expect((error as StreamableHTTPError).message).toContain('Failed to open SSE stream'); + }); + it('should handle successful initial GET connection for SSE', async () => { // Set up readable stream for SSE events const encoder = new TextEncoder(); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 47a3a93bec..2ecb45c043 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -149,7 +149,8 @@ export const REQUIREMENTS: Record = { }, 'typescript:protocol:error:connection-closed': { source: 'sdk', - behavior: 'Closing the transport invokes onclose and rejects all in-flight requests with ErrorCode.ConnectionClosed.', + behavior: + 'Closing the transport invokes onclose and rejects all in-flight requests with ErrorCode.ConnectionClosed — literal code -32000 and a message containing "Connection closed". Both halves of that duck shape are consumer-matched ABI (consumers avoid instanceof across package boundaries), so the literals are pinned, not just the enum member.', knownFailures: [ { transport: 'stdio', @@ -1465,6 +1466,36 @@ export const REQUIREMENTS: Record = { 'ClientOptions.jsonSchemaValidator swaps the JSON Schema validator implementation: the configured provider is the one consulted for client-side tool outputSchema validation and its verdicts are honored.' }, + // Strict/loose validation boundaries (SDK) + + 'typescript:types:empty-result-strict': { + source: 'sdk', + behavior: + 'Empty-result acks (e.g. ping) are validated with a strict schema: a server result carrying an extra non-_meta field rejects client-side with the raw validator error instead of resolving.' + }, + 'typescript:types:envelope-strict': { + source: 'sdk', + behavior: + 'The JSON-RPC envelope schemas are strict: an otherwise-valid request carrying an unknown top-level sibling field is rejected at the wire boundary — HTTP hosting answers 400 with a -32700 error body, and the stdio server transport reports onerror, drops the message, and keeps serving subsequent requests.', + transports: ['stdio', 'streamableHttp'], + note: 'The envelope schemas run where wire bytes are deserialized: the stdio read buffer and the HTTP POST body parser (exercised against the stateless host under the streamableHttp-labelled cell; the legacy SSE POST path runs the same schema parse). inMemory delivers structured objects with no deserialization, so the boundary does not exist there.' + }, + 'typescript:types:request-params-strip': { + source: 'sdk', + behavior: + 'Typed request params schemas operate in strip mode: an unknown sibling field sent next to declared params (e.g. on tools/call) is accepted — not rejected — and is absent from the params the server-side handler receives.' + }, + 'typescript:types:result-passthrough': { + source: 'sdk', + behavior: + 'Typed result schemas are loose per-type: unknown top-level sibling fields a server attaches to a tools/call result survive the client-side result parse and are surfaced to the consumer alongside the declared fields.' + }, + 'typescript:types:completion-result-loose': { + source: 'sdk', + behavior: + 'The completion object inside a completion/complete result is loose: a raw handler can attach unknown sibling fields next to values and they reach the requesting client intact.' + }, + // Hosting: session lifecycle 'hosting:session:cors-expose': { @@ -1526,6 +1557,13 @@ export const REQUIREMENTS: Record = { } ] }, + 'typescript:consumer:session-expiry-message': { + source: 'sdk', + behavior: + "When the documented per-session hosting pattern rejects an unrecognized session id (HTTP 400, JSON-RPC error body whose message contains 'No valid session ID'), the client surfaces the HTTP status as StreamableHTTPError.code and embeds the raw body text in the error message. Consumers detect session expiry by regex-matching that message and reading .code, so both are pinned ABI.", + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, 'hosting:stateless:concurrent-clients': { source: 'sdk', behavior: 'Multiple independent clients can connect to a stateless server concurrently.', @@ -2341,6 +2379,18 @@ export const REQUIREMENTS: Record = { note: 'This is a multi-hop proxy flow that should work across transports; restricted to inMemory and streamableHttp to avoid test matrix bloat.' }, // Consumer-contract additions (sourced from real SDK dependents) + 'typescript:consumer:result-validation-error': { + source: 'sdk', + behavior: + 'When a response result fails the consumer-supplied result schema, client.request rejects with the raw validator error — an issues-bearing error that is not an McpError and has no JSON-RPC error code — so consumers can distinguish local validation failures from remote protocol errors.' + }, + 'typescript:consumer:connect-validation-error': { + source: 'sdk', + behavior: + 'When the server answers initialize with a schema-invalid body, Client.connect rejects with the raw validator error (issues-bearing, not an McpError) and detaches the transport; consumers branch on this error class to route auth and failure handling.', + transports: ['inMemory'], + note: 'The test supplies its own custom Transport implementation answering initialize with an invalid body, so the matrix transport arg is ignored; it runs as a single inMemory-labelled cell to avoid duplicate runs.' + }, 'client-transport:http:error-status-code': { source: 'sdk', behavior: diff --git a/test/e2e/scenarios/boundary.test.ts b/test/e2e/scenarios/boundary.test.ts new file mode 100644 index 0000000000..ba6d476818 --- /dev/null +++ b/test/e2e/scenarios/boundary.test.ts @@ -0,0 +1,264 @@ +/** + * Strict/loose validation-boundary tests plus the client-side result-parse layer. + * + * The SDK's Zod schemas draw a deliberate accept/strip/reject line at each wire + * boundary: JSON-RPC envelopes are strict, empty-result acks are strict, typed + * request params strip unknown siblings, and typed results pass unknown siblings + * through to the consumer. These tests pin that line per boundary so an additive + * protocol revision cannot silently move it, and pin that a result failing the + * consumer-supplied schema rejects with the raw validator error (never a wrapped + * protocol error). + */ + +import { PassThrough } from 'node:stream'; + +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; +import { $ZodError } from 'zod/v4/core'; + +import { Client } from '../../../src/client/index.js'; +import { Server } from '../../../src/server/index.js'; +import { StdioServerTransport } from '../../../src/server/stdio.js'; +import { ReadBuffer, serializeMessage } from '../../../src/shared/stdio.js'; +import type { Transport as SdkTransport } from '../../../src/shared/transport.js'; +import { + CallToolRequestSchema, + CallToolResultSchema, + CompleteRequestSchema, + type JSONRPCMessage, + ListToolsRequestSchema, + McpError, + PingRequestSchema +} from '../../../src/types.js'; + +import { hostStateless, wire } from '../helpers/index.js'; +import type { TestArgs } from '../types.js'; +import { verifies } from '../helpers/verifies.js'; + +const newClient = () => new Client({ name: 'c', version: '0' }); + +/** Issue codes off a raw validator error, typed loosely so the assertion does not depend on zod's generics. */ +const issueCodes = (err: unknown): string[] => ((err as { issues?: Array<{ code: string }> }).issues ?? []).map(i => i.code); + +verifies('typescript:types:empty-result-strict', async ({ transport }: TestArgs) => { + const makeServer = () => { + const s = new Server({ name: 's', version: '0' }, { capabilities: {} }); + // Deliberately violate the empty-result contract: an extra non-_meta key on the ping ack. + s.setRequestHandler(PingRequestSchema, () => ({ ok: true })); + return s; + }; + const client = newClient(); + // The server intentionally puts a non-conforming MCP result on the wire. + await using _ = await wire(transport, makeServer, client, { strictValidation: false }); + + const err: unknown = await client.ping().then( + () => undefined, + (e: unknown) => e + ); + + // EmptyResultSchema is strict: the extra key rejects client-side with the raw validator error. + expect(err).toBeDefined(); + expect(err).not.toBeInstanceOf(McpError); + expect(err).toBeInstanceOf($ZodError); + expect(issueCodes(err)).toContain('unrecognized_keys'); +}); + +verifies('typescript:types:envelope-strict', async ({ transport, protocolVersion }: TestArgs) => { + if (transport === 'streamableHttp') { + // HTTP arm: an otherwise-valid request envelope with an unknown top-level sibling is + // rejected at the body parser — never dispatched — with a -32700 error response. + const { handleRequest, close } = hostStateless(() => new Server({ name: 's', version: '0' }, { capabilities: {} })); + try { + const res = await handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { + 'mcp-protocol-version': protocolVersion, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping', params: {}, extraTop: true }) + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body).toMatchObject({ jsonrpc: '2.0', error: { code: -32700 } }); + } finally { + await close(); + } + return; + } + + // stdio arm: the same envelope on the wire surfaces via onerror, the message is dropped + // (no response is ever produced for its id), and the connection keeps serving requests. + const errors: Error[] = []; + const server = new Server({ name: 's', version: '0' }, { capabilities: {} }); + server.onerror = e => errors.push(e); + const clientToServer = new PassThrough(); + const serverToClient = new PassThrough(); + await server.connect(new StdioServerTransport(clientToServer, serverToClient)); + + const received: JSONRPCMessage[] = []; + const readBuffer = new ReadBuffer(); + serverToClient.on('data', chunk => { + readBuffer.append(chunk); + let message: JSONRPCMessage | null; + while ((message = readBuffer.readMessage())) received.push(message); + }); + const responsesFor = (id: number) => received.filter(m => 'id' in m && m.id === id); + + try { + clientToServer.write( + serializeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion, capabilities: {}, clientInfo: { name: 'raw-boundary-client', version: '0' } } + }) + ); + await vi.waitFor(() => expect(responsesFor(1)).toHaveLength(1)); + + clientToServer.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'ping', params: {}, extraTop: true }) + '\n'); + await vi.waitFor(() => expect(errors.length).toBeGreaterThan(0)); + + clientToServer.write(serializeMessage({ jsonrpc: '2.0', id: 3, method: 'ping' })); + await vi.waitFor(() => expect(responsesFor(3)).toHaveLength(1)); + + // The malformed envelope was dropped, not answered or dispatched. + expect(responsesFor(2)).toEqual([]); + } finally { + await server.close(); + } +}); + +verifies('typescript:types:request-params-strip', async ({ transport }: TestArgs) => { + const seenParams: unknown[] = []; + const makeServer = () => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler(CallToolRequestSchema, req => { + seenParams.push(req.params); + return { content: [{ type: 'text', text: 'ok' }] }; + }); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const result = await client.request( + { method: 'tools/call', params: { name: 'echo', arguments: {}, future2026: 1 } }, + CallToolResultSchema + ); + + // The unknown sibling param is accepted (not rejected) and stripped before the handler sees it. + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(seenParams).toHaveLength(1); + expect(seenParams[0]).toEqual({ name: 'echo', arguments: {} }); +}); + +verifies('typescript:types:result-passthrough', async ({ transport }: TestArgs) => { + const makeServer = () => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler(CallToolRequestSchema, () => ({ + content: [{ type: 'text', text: 'metered' }], + resultType: 'complete', + ttlMs: 5 + })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'cached_call', arguments: {} }); + + expect(result.content).toEqual([{ type: 'text', text: 'metered' }]); + // Unknown top-level result siblings survive the typed result parse and reach the consumer. + expect(result.resultType).toBe('complete'); + expect(result.ttlMs).toBe(5); +}); + +verifies('typescript:types:completion-result-loose', async ({ transport }: TestArgs) => { + const makeServer = () => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { completions: {} } }); + s.setRequestHandler(CompleteRequestSchema, () => ({ completion: { values: ['alpha'], extraField: 'kept' } })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const result = await client.complete({ + ref: { type: 'ref/prompt', name: 'greeting' }, + argument: { name: 'style', value: 'a' } + }); + + expect(result.completion.values).toEqual(['alpha']); + // The completion object is loose: unknown sibling fields are preserved for the consumer. + expect(result.completion.extraField).toBe('kept'); +}); + +verifies('typescript:consumer:result-validation-error', async ({ transport }: TestArgs) => { + const makeServer = () => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + // Sanity: the same request resolves when the consumer-supplied schema matches the result. + const ok = await client.request({ method: 'tools/list' }, z.object({ tools: z.array(z.unknown()) })); + expect(ok.tools).toEqual([]); + + const err: unknown = await client.request({ method: 'tools/list' }, z.object({ impossible: z.literal('x') })).then( + () => undefined, + (e: unknown) => e + ); + + // The raw validator error crosses the boundary: issues-bearing, not wrapped into McpError, + // and without a JSON-RPC error code — consumers tell local validation failures from remote errors by this split. + expect(err).toBeDefined(); + expect(err).toBeInstanceOf($ZodError); + expect(err).not.toBeInstanceOf(McpError); + expect(issueCodes(err).length).toBeGreaterThan(0); + expect((err as { code?: unknown }).code).toBeUndefined(); +}); + +/** Consumer-implemented Transport that answers initialize with a schema-invalid result body. */ +class MalformedInitializeTransport implements SdkTransport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + async start(): Promise {} + + async send(message: JSONRPCMessage): Promise { + if (!('method' in message) || !('id' in message) || message.method !== 'initialize') return; + queueMicrotask(() => + this.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: 42, capabilities: 'invalid', serverInfo: null } + }) + ); + } + + async close(): Promise { + this.onclose?.(); + } +} + +verifies('typescript:consumer:connect-validation-error', async (_: TestArgs) => { + const client = newClient(); + + const err: unknown = await client.connect(new MalformedInitializeTransport()).then( + () => undefined, + (e: unknown) => e + ); + + // The raw validator error from the malformed initialize result crosses connect() unwrapped. + expect(err).toBeDefined(); + expect(err).toBeInstanceOf($ZodError); + expect(err).not.toBeInstanceOf(McpError); + expect(issueCodes(err).length).toBeGreaterThan(0); + // connect() failed cleanly: the transport was closed and detached. + expect(client.transport).toBeUndefined(); +}); diff --git a/test/e2e/scenarios/hosting-session.test.ts b/test/e2e/scenarios/hosting-session.test.ts index f80edec91b..e2696f6588 100644 --- a/test/e2e/scenarios/hosting-session.test.ts +++ b/test/e2e/scenarios/hosting-session.test.ts @@ -17,7 +17,7 @@ import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; import { Client } from '../../../src/client/index.js'; -import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js'; +import { StreamableHTTPClientTransport, StreamableHTTPError } from '../../../src/client/streamableHttp.js'; import { McpServer } from '../../../src/server/mcp.js'; import { StreamableHTTPServerTransport } from '../../../src/server/streamableHttp.js'; import { WebStandardStreamableHTTPServerTransport } from '../../../src/server/webStandardStreamableHttp.js'; @@ -194,6 +194,33 @@ verifies('hosting:session:unknown-id', async (_args: TestArgs) => { } }); +verifies('typescript:consumer:session-expiry-message', async (_args: TestArgs) => { + // The per-session host rejects unrecognized session ids with HTTP 400 and a body containing + // 'No valid session ID' (the documented hosting pattern). Consumers regex-match that string + // on the client-side error message and read the HTTP status off .code to detect session + // expiry, so the client must surface both through the rejection. + const host = hostPerSession(() => echoServer()); + const url = new URL('http://in-process/mcp'); + const fetch = (u: URL | string, init?: RequestInit) => host.handleRequest(new Request(u, init)); + + const client = newClient(); + try { + await client.connect(new StreamableHTTPClientTransport(url, { fetch })); + await client.listTools(); + + // Drop every server-side session while the client still holds its session id. + await host.close(); + + const err = await client.listTools().catch((e: unknown) => e); + expect(err).toBeInstanceOf(StreamableHTTPError); + expect(err).toMatchObject({ code: 400 }); + expect((err as Error).message).toMatch(/No valid session ID/i); + } finally { + await client.close(); + await host.close(); + } +}); + verifies('hosting:session:missing-id', async (_args: TestArgs) => { // Initialize the transport first so the missing-header branch is hit, not the uninitialized-server branch. const server = echoServer(); diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 16a4d18c15..69e02470ff 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -329,6 +329,10 @@ verifies('typescript:protocol:error:connection-closed', async ({ transport }: Te for (const p of inFlight) { await expect(p).rejects.toBeInstanceOf(McpError); await expect(p).rejects.toMatchObject({ code: ErrorCode.ConnectionClosed }); + // Consumers match the close rejection as a duck shape — literal code -32000 plus a + // message containing 'Connection closed' — so pin both halves, not just the enum member. + await expect(p).rejects.toMatchObject({ code: -32000 }); + await expect(p).rejects.toThrow('Connection closed'); } // onclose fires at least once (transport peers may echo a close back, so don't pin the count). await vi.waitFor(() => expect(onclose).toHaveBeenCalled()); diff --git a/test/spec.types.test.ts b/test/spec.types.test.ts index 1fff0f0ffd..da089f9841 100644 --- a/test/spec.types.test.ts +++ b/test/spec.types.test.ts @@ -689,6 +689,34 @@ const sdkTypeChecks = { } }; +// ---- Key-existence checks for consumer-read result members ---- +// +// The mutual-assignability checks above cannot catch a rename or removal of an OPTIONAL member on a +// passthrough/loose result type: in each direction the old key is absorbed by the catchall index +// signature and the renamed key is optional, so `sdk = spec; spec = sdk;` still compiles (verified: +// renaming CallToolResult's `isError` to `isFailure` leaves every assignability check green). +// Consumers read the members below by name on results, so each must remain a *declared* key of the +// SDK type. KnownKeyOf strips string/number index signatures so that only declared keys count. +type KnownKeyOf = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + +const abiKeys = + () => + & string>(...keys: K[]): K[] => + keys; + +const sdkKeyExistenceChecks = { + CallToolResult: abiKeys()('content', 'structuredContent', 'isError', '_meta'), + InitializeResult: abiKeys()('protocolVersion', 'capabilities', 'serverInfo', 'instructions'), + ServerCapabilities: abiKeys()('experimental', 'completions'), + ListToolsResult: abiKeys()('tools', 'nextCursor'), + ListResourcesResult: abiKeys()('resources', 'nextCursor'), + ListResourceTemplatesResult: abiKeys()('resourceTemplates', 'nextCursor'), + ListPromptsResult: abiKeys()('prompts', 'nextCursor'), + GetPromptResult: abiKeys()('messages'), + ReadResourceResult: abiKeys()('contents'), + CompleteResult: abiKeys()('completion') +}; + // This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) const SPEC_TYPES_FILE = 'src/spec.types.ts'; const SDK_TYPES_FILE = 'src/types.ts'; @@ -737,4 +765,26 @@ describe('Spec Types', () => { expect(sdkTypeChecks[type as keyof typeof sdkTypeChecks]).toBeUndefined(); }); }); + + describe('Key Existence', () => { + it('should keep every consumer-read result member as a declared key', () => { + expect(sdkKeyExistenceChecks.CallToolResult).toEqual(['content', 'structuredContent', 'isError', '_meta']); + for (const keys of Object.values(sdkKeyExistenceChecks)) { + expect(keys.length).toBeGreaterThan(0); + } + }); + + it('should preserve isError and sibling members through CallToolResultSchema.parse', () => { + const result = SDKTypes.CallToolResultSchema.parse({ + content: [{ type: 'text', text: 'ok' }], + structuredContent: { ok: true }, + isError: true, + _meta: { example: 'value' } + }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ ok: true }); + expect(result._meta).toEqual({ example: 'value' }); + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]); + }); + }); }); diff --git a/test/types.test.ts b/test/types.test.ts index 78e5bf5a7d..30c890130e 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -14,7 +14,9 @@ import { CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - ClientCapabilitiesSchema + ClientCapabilitiesSchema, + ErrorCode, + McpError } from '../src/types.js'; describe('Types', () => { @@ -983,4 +985,38 @@ describe('Types', () => { } }); }); + + describe('ErrorCode', () => { + test('numeric values are frozen ABI', () => { + // Consumers map error codes by numeric value (value-to-label tables, duck-typed + // {code} checks across package boundaries), so the literal values are public ABI: + // renumbering a member or adding/removing one is a breaking change even though + // every enum-relative comparison in this repo would still pass. Exact-equality on + // the whole table also locks membership in both directions. + const members = Object.fromEntries(Object.entries(ErrorCode).filter(([key]) => Number.isNaN(Number(key)))); + expect(members).toEqual({ + ConnectionClosed: -32000, + RequestTimeout: -32001, + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, + UrlElicitationRequired: -32042 + }); + }); + }); + + describe('McpError', () => { + test('sets error.name to McpError', () => { + // Consumers classify SDK errors via err.name === 'McpError' (an instanceof check + // breaks under dual-package installs), which relies on the constructor assigning + // this.name rather than the class name surviving minification. + const error = new McpError(ErrorCode.InvalidParams, 'oops', { extra: 1 }); + expect(error.name).toBe('McpError'); + expect(error.code).toBe(ErrorCode.InvalidParams); + expect(error.data).toEqual({ extra: 1 }); + expect(error.message).toBe('MCP error -32602: oops'); + }); + }); });