diff --git a/docs/behavior-surface-pins.md b/docs/behavior-surface-pins.md new file mode 100644 index 0000000000..199712e102 --- /dev/null +++ b/docs/behavior-surface-pins.md @@ -0,0 +1,49 @@ +# Behavior-surface pins + +Some tests in this repo are **pins**: they assert the exact current value of a +wire- or consumer-visible behavior — an error code, a schema boundary, an +export map, the stdio env safelist — rather than checking that a feature +works. Their job is to distinguish a deliberate surface change from an +accidental one: the regular suite stays green through either; a pin goes red +through both. + +## When a pin goes red on your change + +A red pin does **not** mean the change is forbidden. It means the change is +surface-visible and must be deliberate: + +1. Confirm the change is intended. If it isn't, the pin just caught an + accidental break. +2. Update the pin in the same PR. +3. Add a changeset if the surface is consumer-facing. +4. Update `docs/migration.md` / `docs/migration-SKILL.md` where consumer-facing. + +Never weaken a pin (loosen an exact match, delete an assertion) just to make +CI pass — that reopens the silent-drift hole the pin exists to close. + +## Where pins live + +| Surface | File | +| --- | --- | +| Wire error-code tables, error classes, version constants | `packages/core/test/types/errorSurfacePins.test.ts` | +| Schema strict/strip/loose boundaries, key existence | `packages/core/test/types/schemaBoundaryPins.test.ts` | +| Published package set, export maps, ESM-only topology | `packages/core/test/packageTopologyPins.test.ts` | +| stdio environment-inheritance safelist | `packages/client/test/client/stdioEnvPins.test.ts` | + +## Writing a new pin + +- The expectation side must be a literal frozen in the test, never a value + imported from src. Comparing a source constant against itself pins nothing. +- Mutation-check it once before landing: flip the source behavior locally and + confirm the pin actually goes red. A pin that stays green under the drift it + claims to guard is worse than no pin. +- Pin behavior a deployed peer or consumer can observe. Internal details that + are invisible across the wire and the public API don't need pins. +- Don't pin a known bug to make it load-bearing — file an issue instead. + +## History + +The original, much broader inventory was developed against v1.x in #2258 and +#2262 (closed unmerged). This sweep ports only the boundary surfaces above; +see those PRs for the fuller exploration and the reasoning behind what was +left out. diff --git a/packages/client/test/client/stdioEnvPins.test.ts b/packages/client/test/client/stdioEnvPins.test.ts new file mode 100644 index 0000000000..35d6d8747d --- /dev/null +++ b/packages/client/test/client/stdioEnvPins.test.ts @@ -0,0 +1,69 @@ +/** + * Behavior-surface pins: the stdio environment-inheritance safelist. + * + * getDefaultEnvironment() decides which parent environment variables every + * spawned stdio server inherits. Widening the safelist leaks more of the + * parent environment into child processes, so both the list itself and the + * filtering behavior are pinned. A failing pin here means the change is + * deliberate: update the pin in the same change, together with a changeset + * and a migration-doc entry. + * + * See docs/behavior-surface-pins.md for the maintenance protocol. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment } from '../../src/client/stdio.js'; + +// Frozen copy of the documented safelist. The expectation side is a literal, +// not derived from src, so any edit to DEFAULT_INHERITED_ENV_VARS goes red +// here regardless of which variables happen to be set in the runner's +// environment. (The behavioral test below cannot catch a widened safelist on +// its own: getDefaultEnvironment skips unset keys, and sensitive variables +// are exactly the ones typically unset in CI.) +const SAFELIST = + process.platform === 'win32' + ? [ + 'APPDATA', + 'HOMEDRIVE', + 'HOMEPATH', + 'LOCALAPPDATA', + 'PATH', + 'PROCESSOR_ARCHITECTURE', + 'SYSTEMDRIVE', + 'SYSTEMROOT', + 'TEMP', + 'USERNAME', + 'USERPROFILE', + 'PROGRAMFILES' + ] + : ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; + +describe('stdio environment safelist', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('DEFAULT_INHERITED_ENV_VARS matches the frozen safelist exactly', () => { + expect([...DEFAULT_INHERITED_ENV_VARS].sort()).toEqual([...SAFELIST].sort()); + }); + + test('getDefaultEnvironment inherits exactly the safelist keys that are set', () => { + for (const key of SAFELIST) { + vi.stubEnv(key, `safe-${key}`); + } + vi.stubEnv('STDIO_PIN_SECRET', 'must-not-be-inherited'); + + const env = getDefaultEnvironment(); + + expect(Object.keys(env).sort()).toEqual([...SAFELIST].sort()); + for (const key of SAFELIST) { + expect(env[key]).toBe(`safe-${key}`); + } + }); + + test('skips values that look like exported shell functions', () => { + vi.stubEnv('PATH', '() { echo pwned; }'); + const env = getDefaultEnvironment(); + expect(env.PATH).toBeUndefined(); + }); +}); diff --git a/packages/core/test/packageTopologyPins.test.ts b/packages/core/test/packageTopologyPins.test.ts new file mode 100644 index 0000000000..9a12a303b6 --- /dev/null +++ b/packages/core/test/packageTopologyPins.test.ts @@ -0,0 +1,146 @@ +/** + * Behavior-surface pins: workspace package topology and export maps. + * + * The published surface of the SDK is the set of public packages and their + * export-map entries. Consumers resolve deep subpaths through these maps, so + * adding, removing, or renaming an entry — or flipping a private flag — is a + * consumer-visible change. This pins the manifest-level topology: every change + * to it must be deliberate (update the pin, add a changeset, and document the + * migration). Runtime resolvability of the built entries is covered by the + * integration test workspace. + * + * See docs/behavior-surface-pins.md for the maintenance protocol. + */ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, test } from 'vitest'; + +const packagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +interface PackageManifest { + name: string; + private?: boolean; + type?: string; + files?: string[]; + bin?: Record; + exports?: Record; +} + +function readManifest(relativeDir: string): PackageManifest { + return JSON.parse(readFileSync(join(packagesDir, relativeDir, 'package.json'), 'utf8')) as PackageManifest; +} + +/** dir (relative to packages/) → expected manifest shape */ +const PUBLIC_PACKAGES: Record }> = { + client: { + name: '@modelcontextprotocol/client', + exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + }, + server: { + name: '@modelcontextprotocol/server', + exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + }, + 'server-legacy': { + name: '@modelcontextprotocol/server-legacy', + exportKeys: ['.', './sse', './auth'] + }, + 'middleware/express': { name: '@modelcontextprotocol/express', exportKeys: ['.'] }, + 'middleware/fastify': { name: '@modelcontextprotocol/fastify', exportKeys: ['.'] }, + 'middleware/hono': { name: '@modelcontextprotocol/hono', exportKeys: ['.'] }, + 'middleware/node': { name: '@modelcontextprotocol/node', exportKeys: ['.'] }, + codemod: { + name: '@modelcontextprotocol/codemod', + exportKeys: ['.'], + bin: { 'mcp-codemod': './dist/cli.mjs' } + } +}; + +describe('public package topology', () => { + for (const [dir, expected] of Object.entries(PUBLIC_PACKAGES)) { + describe(expected.name, () => { + const manifest = readManifest(dir); + + test('is published under the pinned name', () => { + expect(manifest.name).toBe(expected.name); + expect(manifest.private).not.toBe(true); + }); + + test('export-map keys are pinned exactly', () => { + expect(Object.keys(manifest.exports ?? {})).toEqual(expected.exportKeys); + }); + + test('ships ESM only', () => { + expect(manifest.type).toBe('module'); + // No entry may grow a 'require' condition: the v2 packages are + // ESM-only by design (a CJS build would be a new public surface). + const conditionsOf = (entry: unknown): string[] => + entry !== null && typeof entry === 'object' + ? Object.entries(entry).flatMap(([key, value]) => [key, ...conditionsOf(value)]) + : []; + for (const entry of Object.values(manifest.exports ?? {})) { + expect(conditionsOf(entry)).not.toContain('require'); + } + }); + + test('publishes only dist', () => { + expect(manifest.files).toEqual(['dist']); + }); + + if (expected.bin) { + test('bin entries are pinned', () => { + expect(manifest.bin).toEqual(expected.bin); + }); + } else { + test('declares no bin entries', () => { + expect(manifest.bin).toBeUndefined(); + }); + } + }); + } +}); + +describe('the package set itself is pinned', () => { + /** Every directory under packages/ (one level, plus middleware/*) holding a package.json. */ + function discoverManifestDirs(): string[] { + const dirs: string[] = []; + for (const entry of readdirSync(packagesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + if (existsSync(join(packagesDir, entry.name, 'package.json'))) { + dirs.push(entry.name); + continue; + } + for (const nested of readdirSync(join(packagesDir, entry.name), { withFileTypes: true })) { + if (nested.isDirectory() && existsSync(join(packagesDir, entry.name, nested.name, 'package.json'))) { + dirs.push(`${entry.name}/${nested.name}`); + } + } + } + return dirs.sort(); + } + + test('every manifest under packages/ is either a pinned public package or core', () => { + // The workspace glob (packages/**/*) auto-adopts any new directory and + // the changesets config publishes every non-private package, so the SET + // of packages is itself published surface. A new package must be added + // to PUBLIC_PACKAGES here deliberately (or pinned as private below) — + // otherwise it would ship to npm without any pin applying to it. + expect(discoverManifestDirs()).toEqual([...Object.keys(PUBLIC_PACKAGES), 'core'].sort()); + }); +}); + +describe('internal packages stay private', () => { + test('@modelcontextprotocol/core is private (bundled into client/server dists)', () => { + const manifest = readManifest('core'); + expect(manifest.name).toBe('@modelcontextprotocol/core'); + expect(manifest.private).toBe(true); + }); + + test('the workspace root is private', () => { + const manifest = JSON.parse(readFileSync(join(packagesDir, '..', 'package.json'), 'utf8')) as PackageManifest; + expect(manifest.private).toBe(true); + }); +}); diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts new file mode 100644 index 0000000000..cb29e1e969 --- /dev/null +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -0,0 +1,160 @@ +/** + * Behavior-surface pins: error codes, error classes, and version constants. + * + * Consumers match SDK errors by literal numeric code, `error.name`, and message + * text — not only by enum member or `instanceof` (which breaks across bundled + * package boundaries). These tests pin the literal values so that a renumber, + * rename, or membership change turns CI red instead of landing silently. A + * failing pin here means the change is deliberate: update the pin in the same + * change, together with a changeset and a migration-doc entry. + * + * See docs/behavior-surface-pins.md for the maintenance protocol. + */ +import { describe, expect, test } from 'vitest'; + +import { SdkError, SdkErrorCode, SdkHttpError } from '../../src/errors/sdkErrors.js'; +import { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + JSONRPC_VERSION, + LATEST_PROTOCOL_VERSION, + METHOD_NOT_FOUND, + PARSE_ERROR, + ProtocolError, + ProtocolErrorCode, + SUPPORTED_PROTOCOL_VERSIONS, + UnsupportedProtocolVersionError, + UrlElicitationRequiredError +} from '../../src/types/index.js'; +import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '../../src/shared/stdio.js'; + +describe('ProtocolErrorCode', () => { + test('numeric values are frozen wire ABI', () => { + // Consumers map wire error codes by numeric value (value-to-label tables, + // duck-typed {code} checks across package boundaries), so the literal values + // are public ABI. Exact-equality on the whole table also locks membership in + // both directions: adding or removing a member is a deliberate act. + const members = Object.fromEntries(Object.entries(ProtocolErrorCode).filter(([key]) => Number.isNaN(Number(key)))); + expect(members).toEqual({ + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, + ResourceNotFound: -32002, + MissingRequiredClientCapability: -32003, + UnsupportedProtocolVersion: -32004, + UrlElicitationRequired: -32042 + }); + }); + + test('bare JSON-RPC constant values are frozen', () => { + expect(PARSE_ERROR).toBe(-32700); + expect(INVALID_REQUEST).toBe(-32600); + expect(METHOD_NOT_FOUND).toBe(-32601); + expect(INVALID_PARAMS).toBe(-32602); + expect(INTERNAL_ERROR).toBe(-32603); + expect(JSONRPC_VERSION).toBe('2.0'); + }); +}); + +describe('SdkErrorCode', () => { + test('string values are frozen ABI', () => { + // SDK errors are local (never serialized to the wire) but consumers still + // branch on the literal string codes, so the values and the membership of + // the enum are pinned in both directions. + expect({ ...SdkErrorCode }).toEqual({ + NotConnected: 'NOT_CONNECTED', + AlreadyConnected: 'ALREADY_CONNECTED', + NotInitialized: 'NOT_INITIALIZED', + CapabilityNotSupported: 'CAPABILITY_NOT_SUPPORTED', + RequestTimeout: 'REQUEST_TIMEOUT', + ConnectionClosed: 'CONNECTION_CLOSED', + SendFailed: 'SEND_FAILED', + InvalidResult: 'INVALID_RESULT', + ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', + ClientHttpAuthentication: 'CLIENT_HTTP_AUTHENTICATION', + ClientHttpForbidden: 'CLIENT_HTTP_FORBIDDEN', + ClientHttpUnexpectedContent: 'CLIENT_HTTP_UNEXPECTED_CONTENT', + ClientHttpFailedToOpenStream: 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM', + ClientHttpFailedToTerminateSession: 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION' + }); + }); +}); + +describe('ProtocolError', () => { + test('sets error.name, carries code/data, and leaves the message verbatim', () => { + // Consumers classify errors via err.name (instanceof breaks when core is + // bundled into both the client and server dists), and read .code/.data as + // a duck shape. The constructor must not decorate the message. + const error = new ProtocolError(ProtocolErrorCode.InvalidParams, 'oops', { extra: 1 }); + expect(error.name).toBe('ProtocolError'); + expect(error.code).toBe(-32602); + expect(error.data).toEqual({ extra: 1 }); + expect(error.message).toBe('oops'); + expect(error).toBeInstanceOf(Error); + }); + + test('fromError materializes typed errors from code + parsed data, not instanceof', () => { + // Cross-bundle recognition contract: typed error classes are reconstructed + // from the wire shape (numeric code + structurally valid data). The inputs + // here are plain values, exactly what arrives across a package boundary. + const urlError = ProtocolError.fromError(-32042, 'elicitation required', { + elicitations: [{ mode: 'url', message: 'visit', url: 'https://example.com', elicitationId: 'e1' }] + }); + expect(urlError).toBeInstanceOf(UrlElicitationRequiredError); + expect((urlError as UrlElicitationRequiredError).elicitations).toHaveLength(1); + + const versionError = ProtocolError.fromError(-32004, 'unsupported', { supported: ['2025-11-25'], requested: '1999-01-01' }); + expect(versionError).toBeInstanceOf(UnsupportedProtocolVersionError); + expect((versionError as UnsupportedProtocolVersionError).supported).toEqual(['2025-11-25']); + expect((versionError as UnsupportedProtocolVersionError).requested).toBe('1999-01-01'); + + // Malformed/missing data falls back to the generic class instead of throwing. + const generic = ProtocolError.fromError(-32004, 'unsupported', { wrong: 'shape' }); + expect(generic).toBeInstanceOf(ProtocolError); + expect(generic).not.toBeInstanceOf(UnsupportedProtocolVersionError); + }); +}); + +describe('SdkError', () => { + test('sets error.name and carries the string code', () => { + const error = new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: 60000 }); + expect(error.name).toBe('SdkError'); + expect(error.code).toBe('REQUEST_TIMEOUT'); + expect(error.data).toEqual({ timeout: 60000 }); + expect(error.message).toBe('Request timed out'); + }); + + test('SdkHttpError carries the HTTP status in data', () => { + const error = new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, 'Failed to open SSE stream: Not Found', { + status: 404, + statusText: 'Not Found' + }); + expect(error.name).toBe('SdkHttpError'); + expect(error.code).toBe('CLIENT_HTTP_FAILED_TO_OPEN_STREAM'); + expect(error.data).toMatchObject({ status: 404 }); + }); +}); + +describe('protocol version constants', () => { + test('values and membership are frozen', () => { + // The supported list is pinned by exact value (not just membership) so a + // naive LATEST bump that silently drops a previous version goes red here. + expect(LATEST_PROTOCOL_VERSION).toBe('2025-11-25'); + expect(DEFAULT_NEGOTIATED_PROTOCOL_VERSION).toBe('2025-03-26'); + expect(SUPPORTED_PROTOCOL_VERSIONS).toEqual(['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(LATEST_PROTOCOL_VERSION); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + }); +}); + +describe('stdio framing constants', () => { + test('the default read-buffer cap is 10 MiB', () => { + // Public export consumed by custom transport authors; raising or lowering + // the cap changes which deployed payloads parse, so the value is pinned. + expect(STDIO_DEFAULT_MAX_BUFFER_SIZE).toBe(10 * 1024 * 1024); + }); +}); diff --git a/packages/core/test/types/schemaBoundaryPins.test.ts b/packages/core/test/types/schemaBoundaryPins.test.ts new file mode 100644 index 0000000000..5cb1f5cccb --- /dev/null +++ b/packages/core/test/types/schemaBoundaryPins.test.ts @@ -0,0 +1,221 @@ +/** + * Behavior-surface pins: the strict/strip/loose line each wire schema draws, + * plus key-existence checks for result members consumers read by name. + * + * The Zod schemas draw a deliberate accept/strip/reject boundary at each layer: + * 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. An additive protocol revision must not silently + * move that line — these pins make any move loud. A failing pin here means the + * change is deliberate: update the pin together with a changeset and a + * migration-doc entry. + * + * See docs/behavior-surface-pins.md for the maintenance protocol. + */ +import { describe, expect, test } from 'vitest'; + +import { + CallToolRequestSchema, + CallToolResultSchema, + CompleteResultSchema, + EmptyResultSchema, + JSONRPCErrorResponseSchema, + JSONRPCNotificationSchema, + JSONRPCRequestSchema, + JSONRPCResultResponseSchema, + RequestMetaEnvelopeSchema, + ResultSchema +} from '../../src/types/index.js'; +import type { + CallToolResult, + CompleteResult, + GetPromptResult, + InitializeResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + ReadResourceResult, + ServerCapabilities +} from '../../src/types/index.js'; + +/** Extract zod issue codes without depending on zod's generics. */ +const issueCodes = (err: unknown): string[] => ((err as { issues?: Array<{ code: string }> }).issues ?? []).map(i => i.code); + +describe('JSON-RPC envelope schemas are strict', () => { + test('a request with an unknown top-level sibling is rejected', () => { + const parsed = JSONRPCRequestSchema.safeParse({ jsonrpc: '2.0', id: 1, method: 'ping', params: {}, extraTop: true }); + expect(parsed.success).toBe(false); + expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); + }); + + test('a notification with an unknown top-level sibling is rejected', () => { + const parsed = JSONRPCNotificationSchema.safeParse({ jsonrpc: '2.0', method: 'notifications/initialized', extraTop: true }); + expect(parsed.success).toBe(false); + expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); + }); + + test('a result response with an unknown top-level sibling is rejected', () => { + const parsed = JSONRPCResultResponseSchema.safeParse({ jsonrpc: '2.0', id: 1, result: {}, extraTop: true }); + expect(parsed.success).toBe(false); + expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); + }); + + test('an error response with an unknown top-level sibling is rejected', () => { + const parsed = JSONRPCErrorResponseSchema.safeParse({ + jsonrpc: '2.0', + id: 1, + error: { code: -32600, message: 'nope' }, + extraTop: true + }); + expect(parsed.success).toBe(false); + expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); + }); +}); + +describe('EmptyResultSchema is strict', () => { + test('an extra non-declared field rejects', () => { + const parsed = EmptyResultSchema.safeParse({ ok: true }); + expect(parsed.success).toBe(false); + expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); + }); + + test('the declared _meta and resultType members are accepted', () => { + expect(EmptyResultSchema.safeParse({}).success).toBe(true); + expect(EmptyResultSchema.safeParse({ _meta: { note: 'x' } }).success).toBe(true); + expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(true); + }); +}); + +describe('typed request params strip unknown siblings', () => { + test('an unknown sibling next to declared tools/call params is accepted and stripped', () => { + const parsed = CallToolRequestSchema.parse({ + method: 'tools/call', + params: { name: 'echo', arguments: {}, future2099: 1 } + }); + expect(parsed.params.name).toBe('echo'); + expect('future2099' in parsed.params).toBe(false); + }); +}); + +describe('typed result schemas are loose', () => { + test('the base ResultSchema declares resultType and passes unknown siblings through', () => { + const parsed = ResultSchema.parse({ resultType: 'complete', futureField: 'kept' }); + expect(parsed.resultType).toBe('complete'); + expect((parsed as Record).futureField).toBe('kept'); + }); + + test('unknown top-level siblings on a tools/call result survive the parse', () => { + const parsed = CallToolResultSchema.parse({ + content: [{ type: 'text', text: 'metered' }], + resultType: 'complete', + ttlMs: 5 + }); + expect(parsed.content).toEqual([{ type: 'text', text: 'metered' }]); + expect(parsed.resultType).toBe('complete'); + expect((parsed as Record).ttlMs).toBe(5); + }); + + test('CallToolResult content defaults to the empty array when absent', () => { + // A tool result may carry only structuredContent; the parse then supplies + // content: [] for backwards compatibility. Removing the default would be a + // consumer-visible change for every result that omits content. + const parsed = CallToolResultSchema.parse({ structuredContent: { ok: true } }); + expect(parsed.content).toEqual([]); + expect(parsed.structuredContent).toEqual({ ok: true }); + }); + + test('CallToolResult preserves isError and sibling members through the parse', () => { + const parsed = CallToolResultSchema.parse({ + content: [{ type: 'text', text: 'ok' }], + structuredContent: { ok: true }, + isError: true, + _meta: { example: 'value' } + }); + expect(parsed.isError).toBe(true); + expect(parsed.structuredContent).toEqual({ ok: true }); + expect(parsed._meta).toEqual({ example: 'value' }); + expect(parsed.content).toEqual([{ type: 'text', text: 'ok' }]); + }); +}); + +describe('completion result boundary', () => { + test('the completion object is loose: unknown sibling fields are preserved', () => { + const parsed = CompleteResultSchema.parse({ completion: { values: ['alpha'], extraField: 'kept' } }); + expect(parsed.completion.values).toEqual(['alpha']); + expect((parsed.completion as Record).extraField).toBe('kept'); + }); + + test('completion.values is capped at 100 entries at the parse boundary', () => { + // The cap is receiver-side ABI: an SDK client cannot observe more than 100 + // values even from a non-SDK server that sends them. + const hundred = Array.from({ length: 100 }, (_, i) => `v${i}`); + expect(CompleteResultSchema.safeParse({ completion: { values: hundred } }).success).toBe(true); + + const overCap = CompleteResultSchema.safeParse({ completion: { values: [...hundred, 'v100'] } }); + expect(overCap.success).toBe(false); + expect(issueCodes(overCap.error)).toContain('too_big'); + }); +}); + +describe('RequestMetaEnvelopeSchema', () => { + const validEnvelope = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'pin-client', version: '0.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} + }; + + test('requires protocolVersion, clientInfo, and clientCapabilities', () => { + expect(RequestMetaEnvelopeSchema.safeParse(validEnvelope).success).toBe(true); + for (const key of Object.keys(validEnvelope)) { + const incomplete: Record = { ...validEnvelope }; + delete incomplete[key]; + expect(RequestMetaEnvelopeSchema.safeParse(incomplete).success).toBe(false); + } + }); + + test('is loose: foreign _meta keys pass through', () => { + const parsed = RequestMetaEnvelopeSchema.parse({ ...validEnvelope, 'com.example/custom': 'kept' }); + expect((parsed as Record)['com.example/custom']).toBe('kept'); + }); +}); + +// ---- Key-existence checks for consumer-read result members ---- +// +// Mutual-assignability checks against the spec types cannot catch a rename or +// removal of an OPTIONAL member on a loose result type: the old key is absorbed +// by the catchall index signature and the renamed key is optional, so the +// assignment compiles in both directions. Consumers read the members below by +// name, 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', 'logging', 'prompts', 'resources', 'tools'), + 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') +}; + +describe('key existence for consumer-read result members', () => { + test('every consumer-read member remains a declared key of its SDK type', () => { + // The compile of `sdkKeyExistenceChecks` above IS the assertion: a renamed + // or removed member fails typecheck. The runtime check guards the table + // itself against accidental truncation. + expect(sdkKeyExistenceChecks.CallToolResult).toEqual(['content', 'structuredContent', 'isError', '_meta']); + for (const keys of Object.values(sdkKeyExistenceChecks)) { + expect(keys.length).toBeGreaterThan(0); + } + }); +});