From 1f3c63d7d3487d34392f38926cc92d6ba53ce70e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 14:42:21 +0000 Subject: [PATCH 01/37] ci: run workflows on pushes to the v2-2026-07-28 integration branch Mirrors the v1.x-2026-07-28 arrangement: pull_request triggers are unfiltered (PRs targeting the branch already get CI); this adds the branch to the push filters so post-merge state runs CI too. --- .github/workflows/conformance.yml | 2 +- .github/workflows/main.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 0deab54482..049b1e8fa0 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -2,7 +2,7 @@ name: Conformance Tests on: push: - branches: [main] + branches: [main, v2-2026-07-28] pull_request: workflow_dispatch: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 44852a93d6..92705a4a3d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,6 +2,8 @@ on: push: branches: - main + - v2-2026-07-28 + - v2-2026-07-28 pull_request: workflow_dispatch: From d55030a72e6d4c3647dfca7372a78df804fda2ee Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:04:50 +0100 Subject: [PATCH 02/37] test: pin schema boundaries, error-code tables, package topology, and the stdio env safelist (#2281) --- docs/behavior-surface-pins.md | 49 ++++ .../client/test/client/stdioEnvPins.test.ts | 69 ++++++ .../core/test/packageTopologyPins.test.ts | 146 ++++++++++++ .../core/test/types/errorSurfacePins.test.ts | 160 +++++++++++++ .../test/types/schemaBoundaryPins.test.ts | 221 ++++++++++++++++++ 5 files changed, 645 insertions(+) create mode 100644 docs/behavior-surface-pins.md create mode 100644 packages/client/test/client/stdioEnvPins.test.ts create mode 100644 packages/core/test/packageTopologyPins.test.ts create mode 100644 packages/core/test/types/errorSurfacePins.test.ts create mode 100644 packages/core/test/types/schemaBoundaryPins.test.ts 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); + } + }); +}); From 7103d48b6d4b05e4b648ac8a228bc5bf7f11a57b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 12 Jun 2026 15:44:10 +0000 Subject: [PATCH 03/37] ci: remove duplicated branch entry in the push filter --- .github/workflows/main.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 92705a4a3d..5686454414 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,7 +3,6 @@ on: branches: - main - v2-2026-07-28 - - v2-2026-07-28 pull_request: workflow_dispatch: From b4d65a89decf9cfbfddaf3c5085d6f51d101a46b Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:51:37 +0100 Subject: [PATCH 04/37] build: committed API reports per public package with a CI gate (#2285) --- .gitattributes | 4 + .github/workflows/main.yml | 25 + .gitignore | 3 + .prettierignore | 4 + CONTRIBUTING.md | 10 + package.json | 7 +- packages/client/etc/client.api.md | 9453 +++++++++++++++++ .../client/etc/client.shims-browser.api.md | 47 + .../client/etc/client.shims-workerd.api.md | 47 + packages/client/etc/client.shims.api.md | 60 + packages/client/etc/client.stdio.api.md | 148 + .../client/etc/client.validators-ajv.api.md | 1572 +++ .../etc/client.validators-cf-worker.api.md | 44 + .../middleware/express/etc/express.api.md | 95 + .../middleware/fastify/etc/fastify.api.md | 27 + packages/middleware/hono/etc/hono.api.md | 26 + packages/middleware/node/etc/node.api.md | 175 + .../server-legacy/etc/server-legacy.api.md | 588 + .../etc/server-legacy.auth.api.md | 459 + .../etc/server-legacy.sse.api.md | 149 + packages/server/etc/server.api.md | 8868 ++++++++++++++++ .../server/etc/server.shims-workerd.api.md | 51 + packages/server/etc/server.shims.api.md | 60 + packages/server/etc/server.stdio.api.md | 138 + .../server/etc/server.validators-ajv.api.md | 1572 +++ .../etc/server.validators-cf-worker.api.md | 44 + pnpm-lock.yaml | 207 + scripts/generate-api-reports.ts | 683 ++ 28 files changed, 24564 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 packages/client/etc/client.api.md create mode 100644 packages/client/etc/client.shims-browser.api.md create mode 100644 packages/client/etc/client.shims-workerd.api.md create mode 100644 packages/client/etc/client.shims.api.md create mode 100644 packages/client/etc/client.stdio.api.md create mode 100644 packages/client/etc/client.validators-ajv.api.md create mode 100644 packages/client/etc/client.validators-cf-worker.api.md create mode 100644 packages/middleware/express/etc/express.api.md create mode 100644 packages/middleware/fastify/etc/fastify.api.md create mode 100644 packages/middleware/hono/etc/hono.api.md create mode 100644 packages/middleware/node/etc/node.api.md create mode 100644 packages/server-legacy/etc/server-legacy.api.md create mode 100644 packages/server-legacy/etc/server-legacy.auth.api.md create mode 100644 packages/server-legacy/etc/server-legacy.sse.api.md create mode 100644 packages/server/etc/server.api.md create mode 100644 packages/server/etc/server.shims-workerd.api.md create mode 100644 packages/server/etc/server.shims.api.md create mode 100644 packages/server/etc/server.stdio.api.md create mode 100644 packages/server/etc/server.validators-ajv.api.md create mode 100644 packages/server/etc/server.validators-cf-worker.api.md create mode 100644 scripts/generate-api-reports.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..7334617a74 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Generated API report baselines: regenerate with `pnpm api-report`, review +# diffs like source, never hand-edit. LF is pinned because the api-report CI +# gate compares them byte-exactly against freshly generated (LF) output. +packages/**/etc/*.api.md linguist-generated=true text eol=lf diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5686454414..f55006abaa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -58,6 +58,31 @@ jobs: # The e2e suite has its own job below; everything else runs here. - run: pnpm -r --filter '!@modelcontextprotocol/test-e2e' test + api-report: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - run: pnpm install + + # Fails when the built public type surface of any package differs + # from its committed API report (packages/*/etc/*.api.md). To accept + # an intentional surface change: run `pnpm api-report`, review the + # report diff, and commit it. See the header of scripts/generate-api-reports.ts. + - run: pnpm run api-report:check + test-e2e: runs-on: ubuntu-latest strategy: diff --git a/.gitignore b/.gitignore index 6372eb1d57..898d0265ff 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ results/ # Ignore local lefthook configuration lefthook-local.yml + +# API report scratch folders (committed reports live in packages/*/etc/) +.api-extractor-tmp/ diff --git a/.prettierignore b/.prettierignore index d2fb242b9d..d161704250 100644 --- a/.prettierignore +++ b/.prettierignore @@ -20,3 +20,7 @@ packages/codemod/batch-test/results # Quickstart examples uses 2-space indent to match ecosystem conventions examples/client-quickstart/ examples/server-quickstart/ + +# Generated API reports (machine-formatted by API Extractor) +packages/**/etc/*.api.md +.api-extractor-tmp diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 325330c15b..25b29efe09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,6 +110,16 @@ Then: 4. Run `pnpm test:all` to verify all tests pass 5. Submit a pull request +### API Reports + +Every public package's type surface is pinned by a committed API report (`packages//etc/.api.md`), and CI fails when the built surface differs from the committed baseline. A failing `api-report` check does not mean your change is wrong — it means it is surface-visible. + +- If the surface change is intentional: run `pnpm api-report` (~30s: builds the packages and regenerates the reports), review the report diff like source, and commit it together with your change — plus a changeset if it is consumer-facing. +- Never hand-edit a report; the check compares byte-exactly against regenerated output. +- Resolve merge conflicts in `.api.md` files by taking either side and rerunning `pnpm api-report`, not by hand-merging. + +See the header of `scripts/generate-api-reports.ts` for how the reports are produced and which packages are covered. + ### Running Examples See [`examples/server/README.md`](examples/server/README.md) and [`examples/client/README.md`](examples/client/README.md) for a full list of runnable examples. diff --git a/package.json b/package.json index d1ecc0c627..6517c23c24 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "lint:fix:all": "pnpm sync:snippets && pnpm -r lint:fix", "check:all": "pnpm -r typecheck && pnpm -r lint && pnpm run docs:check", "test:all": "pnpm -r test", + "api-report": "pnpm --filter \"./packages/**\" build && tsx scripts/generate-api-reports.ts", + "api-report:check": "pnpm --filter \"./packages/**\" build && tsx scripts/generate-api-reports.ts --check", "prepare": "npx --no-install lefthook install", "test:conformance:client": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client", "test:conformance:client:all": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:all", @@ -46,14 +48,14 @@ "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "lefthook": "^2.0.16", "@cfworker/json-schema": "catalog:runtimeShared", "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", "@eslint/js": "catalog:devTools", + "@microsoft/api-extractor": "7.58.7", "@modelcontextprotocol/client": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", "@types/content-type": "catalog:devTools", "@types/cors": "catalog:devTools", "@types/cross-spawn": "catalog:devTools", @@ -67,6 +69,7 @@ "eslint-config-prettier": "catalog:devTools", "eslint-plugin-n": "catalog:devTools", "fast-glob": "^3.3.3", + "lefthook": "^2.0.16", "prettier": "catalog:devTools", "supertest": "catalog:devTools", "tsdown": "catalog:devTools", diff --git a/packages/client/etc/client.api.md b/packages/client/etc/client.api.md new file mode 100644 index 0000000000..af41d5f6e4 --- /dev/null +++ b/packages/client/etc/client.api.md @@ -0,0 +1,9453 @@ +## API Report File for "@modelcontextprotocol/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ErrorEvent as ErrorEvent_2 } from 'eventsource'; +import { EventSourceInit as EventSourceInit_2 } from 'eventsource'; +import { JSONSchema } from 'json-schema-typed'; +import * as z from 'zod/v4'; + +// @public +export type AddClientAuthentication = (headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata) => void | Promise; + +// @public +export class AjvJsonSchemaValidator implements jsonSchemaValidator { + constructor(ajv?: AjvLike); + // (undocumented) + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +interface AjvLike { + // (undocumented) + compile: (schema: unknown) => AjvValidateFunction; + // (undocumented) + errorsText: (errors?: any) => string; + // (undocumented) + getSchema: (keyRef: string) => AjvValidateFunction | undefined; +} + +// @public (undocumented) +interface AjvValidateFunction { + // (undocumented) + (input: unknown): boolean; + // (undocumented) + errors?: any; +} + +// @public (undocumented) +export type Annotations = Infer; + +// @public +const AnnotationsSchema: z.ZodObject<{ + audience: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; +}, z.core.$strip>; + +// @public +export type AssertionCallback = (context: CrossAppAccessContext) => string | Promise; + +// @public (undocumented) +export type AudioContent = Infer; + +// @public +const AudioContentSchema: z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public +export interface AuthInfo { + clientId: string; + expiresAt?: number; + extra?: Record; + resource?: URL; + scopes: string[]; + token: string; +} + +// @public +export interface AuthProvider { + onUnauthorized?(ctx: UnauthorizedContext): Promise; + token(): Promise; +} + +// @public (undocumented) +export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; + +// @public (undocumented) +type AuthSchemaKey = keyof typeof authSchemas; + +// @public (undocumented) +export type AuthorizationServerMetadata = OAuthMetadata | OpenIdProviderDiscoveryMetadata; + +// @public +export type BaseContext = { + sessionId?: string; + mcpReq: { + id: RequestId; + method: string; + _meta?: RequestMeta; + signal: AbortSignal; + send: { + (request: { + method: M; + params?: Record; + }, options?: RequestOptions): Promise; + (request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; + }; + notify: (notification: Notification_2) => Promise; + }; + http?: { + authInfo?: AuthInfo; + }; +}; + +// @public (undocumented) +export type BaseMetadata = Infer; + +// @public +const BaseMetadataSchema: z.ZodObject<{ + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public +const BaseRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; +}, z.core.$strip>; + +// @public (undocumented) +export type BlobResourceContents = Infer; + +// @public (undocumented) +const BlobResourceContentsSchema: z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; +}, z.core.$strip>; + +// @public (undocumented) +export type BooleanSchema = Infer; + +// @public +const BooleanSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public +export const CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities"; + +// @public +export const CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo"; + +// @public (undocumented) +export type CallToolRequest = Infer; + +// @public (undocumented) +export type CallToolRequestParams = Infer; + +// @public +const CallToolRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + name: z.ZodString; + arguments: z.ZodOptional>; +}, z.core.$strip>; + +// @public +const CallToolRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"tools/call">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + name: z.ZodString; + arguments: z.ZodOptional>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type CallToolResult = Infer; + +// @public +const CallToolResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type CancelTaskRequest = Infer; + +// @public +const CancelTaskRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"tasks/cancel">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type CancelTaskResult = Infer; + +// @public +const CancelTaskResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type CancelledNotification = Infer; + +// @public (undocumented) +export type CancelledNotificationParams = Infer; + +// @public (undocumented) +const CancelledNotificationParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + requestId: z.ZodOptional>; + reason: z.ZodOptional; +}, z.core.$strip>; + +// @public +const CancelledNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/cancelled">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + requestId: z.ZodOptional>; + reason: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public +export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { + constructor(options?: { + shortcircuit?: boolean; + draft?: CfWorkerSchemaDraft; + }); + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; + +// @public +export class Client extends Protocol { + constructor(_clientInfo: Implementation, options?: ClientOptions); + // (undocumented) + protected assertCapability(capability: keyof ServerCapabilities, method: string): void; + // (undocumented) + protected assertCapabilityForMethod(method: RequestMethod | string): void; + // (undocumented) + protected assertNotificationCapability(method: NotificationMethod | string): void; + // (undocumented) + protected assertRequestHandlerCapability(method: string): void; + // (undocumented) + protected buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext; + callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise<{ + [x: string]: unknown; + content: ({ + type: "text"; + text: string; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: Record | undefined; + } | { + type: "image"; + data: string; + mimeType: string; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: Record | undefined; + } | { + type: "audio"; + data: string; + mimeType: string; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: Record | undefined; + } | { + uri: string; + name: string; + type: "resource_link"; + description?: string | undefined; + mimeType?: string | undefined; + size?: number | undefined; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: { + [x: string]: unknown; + } | undefined; + icons?: { + src: string; + mimeType?: string | undefined; + sizes?: string[] | undefined; + theme?: "light" | "dark" | undefined; + }[] | undefined; + title?: string | undefined; + } | { + type: "resource"; + resource: { + uri: string; + text: string; + mimeType?: string | undefined; + _meta?: Record | undefined; + } | { + uri: string; + blob: string; + mimeType?: string | undefined; + _meta?: Record | undefined; + }; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: Record | undefined; + })[]; + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + structuredContent?: Record | undefined; + isError?: boolean | undefined; + }>; + complete(params: CompleteRequest['params'], options?: RequestOptions): Promise<{ + [x: string]: unknown; + completion: { + [x: string]: unknown; + values: string[]; + total?: number | undefined; + hasMore?: boolean | undefined; + }; + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + }>; + connect(transport: Transport, options?: RequestOptions): Promise; + getInstructions(): string | undefined; + getNegotiatedProtocolVersion(): string | undefined; + getPrompt(params: GetPromptRequest['params'], options?: RequestOptions): Promise<{ + [x: string]: unknown; + messages: { + role: "user" | "assistant"; + content: { + type: "text"; + text: string; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: Record | undefined; + } | { + type: "image"; + data: string; + mimeType: string; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: Record | undefined; + } | { + type: "audio"; + data: string; + mimeType: string; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: Record | undefined; + } | { + uri: string; + name: string; + type: "resource_link"; + description?: string | undefined; + mimeType?: string | undefined; + size?: number | undefined; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: { + [x: string]: unknown; + } | undefined; + icons?: { + src: string; + mimeType?: string | undefined; + sizes?: string[] | undefined; + theme?: "light" | "dark" | undefined; + }[] | undefined; + title?: string | undefined; + } | { + type: "resource"; + resource: { + uri: string; + text: string; + mimeType?: string | undefined; + _meta?: Record | undefined; + } | { + uri: string; + blob: string; + mimeType?: string | undefined; + _meta?: Record | undefined; + }; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: Record | undefined; + }; + }[]; + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + description?: string | undefined; + }>; + getServerCapabilities(): ServerCapabilities | undefined; + getServerVersion(): Implementation | undefined; + listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions): Promise<{ + [x: string]: unknown; + prompts: { + name: string; + description?: string | undefined; + arguments?: { + name: string; + description?: string | undefined; + required?: boolean | undefined; + }[] | undefined; + _meta?: { + [x: string]: unknown; + } | undefined; + icons?: { + src: string; + mimeType?: string | undefined; + sizes?: string[] | undefined; + theme?: "light" | "dark" | undefined; + }[] | undefined; + title?: string | undefined; + }[]; + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + nextCursor?: string | undefined; + }>; + listResources(params?: ListResourcesRequest['params'], options?: RequestOptions): Promise<{ + [x: string]: unknown; + resources: { + uri: string; + name: string; + description?: string | undefined; + mimeType?: string | undefined; + size?: number | undefined; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: { + [x: string]: unknown; + } | undefined; + icons?: { + src: string; + mimeType?: string | undefined; + sizes?: string[] | undefined; + theme?: "light" | "dark" | undefined; + }[] | undefined; + title?: string | undefined; + }[]; + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + nextCursor?: string | undefined; + }>; + listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions): Promise<{ + [x: string]: unknown; + resourceTemplates: { + uriTemplate: string; + name: string; + description?: string | undefined; + mimeType?: string | undefined; + annotations?: { + audience?: ("user" | "assistant")[] | undefined; + priority?: number | undefined; + lastModified?: string | undefined; + } | undefined; + _meta?: { + [x: string]: unknown; + } | undefined; + icons?: { + src: string; + mimeType?: string | undefined; + sizes?: string[] | undefined; + theme?: "light" | "dark" | undefined; + }[] | undefined; + title?: string | undefined; + }[]; + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + nextCursor?: string | undefined; + }>; + listTools(params?: ListToolsRequest['params'], options?: RequestOptions): Promise<{ + [x: string]: unknown; + tools: { + inputSchema: { + [x: string]: unknown; + type: "object"; + properties?: Record | undefined; + required?: string[] | undefined; + }; + name: string; + description?: string | undefined; + outputSchema?: { + [x: string]: unknown; + type: "object"; + properties?: Record | undefined; + required?: string[] | undefined; + } | undefined; + annotations?: { + title?: string | undefined; + readOnlyHint?: boolean | undefined; + destructiveHint?: boolean | undefined; + idempotentHint?: boolean | undefined; + openWorldHint?: boolean | undefined; + } | undefined; + execution?: { + taskSupport?: "optional" | "required" | "forbidden" | undefined; + } | undefined; + _meta?: Record | undefined; + icons?: { + src: string; + mimeType?: string | undefined; + sizes?: string[] | undefined; + theme?: "light" | "dark" | undefined; + }[] | undefined; + title?: string | undefined; + }[]; + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + nextCursor?: string | undefined; + }>; + // (undocumented) + ping(options?: RequestOptions): Promise<{ + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + }>; + readResource(params: ReadResourceRequest['params'], options?: RequestOptions): Promise<{ + [x: string]: unknown; + contents: ({ + uri: string; + text: string; + mimeType?: string | undefined; + _meta?: Record | undefined; + } | { + uri: string; + blob: string; + mimeType?: string | undefined; + _meta?: Record | undefined; + })[]; + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + }>; + registerCapabilities(capabilities: ClientCapabilities): void; + sendRootsListChanged(): Promise; + setLoggingLevel(level: LoggingLevel, options?: RequestOptions): Promise<{ + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + }>; + subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions): Promise<{ + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + }>; + unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions): Promise<{ + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + }>; + protected _wrapHandler(method: string, handler: (request: JSONRPCRequest, ctx: ClientContext) => Promise): (request: JSONRPCRequest, ctx: ClientContext) => Promise; +} + +// @public (undocumented) +export type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; + +// @public (undocumented) +export type ClientCapabilities = Infer; + +// @public +const ClientCapabilitiesSchema: z.ZodObject<{ + experimental: z.ZodOptional>>>; + sampling: z.ZodOptional>>; + tools: z.ZodOptional>>; + }, z.core.$strip>>; + elicitation: z.ZodOptional, z.ZodIntersection; + }, z.core.$strip>, z.ZodType>>>; + url: z.ZodOptional>>; + }, z.core.$strip>, z.ZodOptional>>>>>; + roots: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + elicitation: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; +}, z.core.$strip>; + +// @public +export type ClientContext = BaseContext; + +// @public +export class ClientCredentialsProvider implements OAuthClientProvider { + constructor(options: ClientCredentialsProviderOptions); + // (undocumented) + clientInformation(): OAuthClientInformation; + // (undocumented) + get clientMetadata(): OAuthClientMetadata; + // (undocumented) + codeVerifier(): string; + // (undocumented) + prepareTokenRequest(scope?: string): URLSearchParams; + // (undocumented) + redirectToAuthorization(): void; + // (undocumented) + get redirectUrl(): undefined; + // (undocumented) + saveClientInformation(info: OAuthClientInformation): void; + // (undocumented) + saveCodeVerifier(): void; + // (undocumented) + saveTokens(tokens: OAuthTokens): void; + // (undocumented) + tokens(): OAuthTokens | undefined; +} + +// @public +export interface ClientCredentialsProviderOptions { + clientId: string; + clientName?: string; + clientSecret: string; + scope?: string; +} + +// @public (undocumented) +export type ClientNotification = Infer; + +// @public (undocumented) +const ClientNotificationSchema: z.ZodUnion; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + requestId: z.ZodOptional>; + reason: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/progress">; + params: z.ZodObject<{ + progressToken: z.ZodUnion; + progress: z.ZodNumber; + total: z.ZodOptional; + message: z.ZodOptional; + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/initialized">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/roots/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/tasks/status">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type ClientOptions = ProtocolOptions & { + capabilities?: ClientCapabilities; + jsonSchemaValidator?: jsonSchemaValidator; + listChanged?: ListChangedHandlers; +}; + +// @public (undocumented) +export type ClientRequest = Infer; + +// @public (undocumented) +const ClientRequestSchema: z.ZodUnion; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"initialize">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + protocolVersion: z.ZodString; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + sampling: z.ZodOptional>>; + tools: z.ZodOptional>>; + }, z.core.$strip>>; + elicitation: z.ZodOptional, z.ZodIntersection; + }, z.core.$strip>, z.ZodType>>>; + url: z.ZodOptional>>; + }, z.core.$strip>, z.ZodOptional>>>>>; + roots: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + elicitation: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + clientInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"completion/complete">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + ref: z.ZodUnion; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"ref/resource">; + uri: z.ZodString; + }, z.core.$strip>]>; + argument: z.ZodObject<{ + name: z.ZodString; + value: z.ZodString; + }, z.core.$strip>; + context: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"logging/setLevel">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"prompts/get">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + name: z.ZodString; + arguments: z.ZodOptional>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"prompts/list">; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"resources/list">; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"resources/templates/list">; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"resources/read">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"resources/subscribe">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"resources/unsubscribe">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tools/call">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + name: z.ZodString; + arguments: z.ZodOptional>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"tools/list">; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/get">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/result">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"tasks/list">; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/cancel">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type ClientResult = Infer; + +// @public (undocumented) +const ClientResultSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$strict>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + model: z.ZodString; + stopReason: z.ZodOptional, z.ZodString]>>; + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"text">; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + model: z.ZodString; + stopReason: z.ZodOptional, z.ZodString]>>; + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + action: z.ZodEnum<{ + cancel: "cancel"; + accept: "accept"; + decline: "decline"; + }>; + content: z.ZodPipe, z.ZodOptional]>>>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + roots: z.ZodArray; + _meta: z.ZodOptional>; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + tasks: z.ZodArray; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + task: z.ZodObject<{ + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$loose>]>; + +// @public (undocumented) +export type CompatibilityCallToolResult = Infer; + +// @public +const CompatibilityCallToolResultSchema: z.ZodUnion<[z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + toolResult: z.ZodUnknown; +}, z.core.$loose>]>; + +// @public (undocumented) +export type CompleteRequest = Infer; + +// @public (undocumented) +export type CompleteRequestParams = Infer; + +// @public +const CompleteRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + ref: z.ZodUnion; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"ref/resource">; + uri: z.ZodString; + }, z.core.$strip>]>; + argument: z.ZodObject<{ + name: z.ZodString; + value: z.ZodString; + }, z.core.$strip>; + context: z.ZodOptional>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type CompleteRequestPrompt = ExpandRecursively; + +// @public (undocumented) +export type CompleteRequestResourceTemplate = ExpandRecursively; + +// @public +const CompleteRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"completion/complete">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + ref: z.ZodUnion; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"ref/resource">; + uri: z.ZodString; + }, z.core.$strip>]>; + argument: z.ZodObject<{ + name: z.ZodString; + value: z.ZodString; + }, z.core.$strip>; + context: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type CompleteResult = Infer; + +// @public +const CompleteResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + completion: z.ZodObject<{ + values: z.ZodArray; + total: z.ZodOptional; + hasMore: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$loose>; + +// @public (undocumented) +export type ContentBlock = Infer; + +// @public +const ContentBlockSchema: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type CreateMessageRequest = Infer; + +// @public (undocumented) +export type CreateMessageRequestParams = Infer; + +// @public +export type CreateMessageRequestParamsBase = Omit; + +// @public +const CreateMessageRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + messages: z.ZodArray; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; + _meta: z.ZodOptional>; + }, z.core.$strip>>; + modelPreferences: z.ZodOptional; + }, z.core.$strip>>>; + costPriority: z.ZodOptional; + speedPriority: z.ZodOptional; + intelligencePriority: z.ZodOptional; + }, z.core.$strip>>; + systemPrompt: z.ZodOptional; + includeContext: z.ZodOptional>; + temperature: z.ZodOptional; + maxTokens: z.ZodNumber; + stopSequences: z.ZodOptional>; + metadata: z.ZodOptional>>; + tools: z.ZodOptional; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>>; + toolChoice: z.ZodOptional>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public +export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams { + // (undocumented) + tools: Tool[]; +} + +// @public +const CreateMessageRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"sampling/createMessage">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + messages: z.ZodArray; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; + _meta: z.ZodOptional>; + }, z.core.$strip>>; + modelPreferences: z.ZodOptional; + }, z.core.$strip>>>; + costPriority: z.ZodOptional; + speedPriority: z.ZodOptional; + intelligencePriority: z.ZodOptional; + }, z.core.$strip>>; + systemPrompt: z.ZodOptional; + includeContext: z.ZodOptional>; + temperature: z.ZodOptional; + maxTokens: z.ZodNumber; + stopSequences: z.ZodOptional>; + metadata: z.ZodOptional>>; + tools: z.ZodOptional; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>>; + toolChoice: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type CreateMessageResult = Infer; + +// @public +const CreateMessageResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + model: z.ZodString; + stopReason: z.ZodOptional, z.ZodString]>>; + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"text">; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">; +}, z.core.$loose>; + +// @public (undocumented) +export type CreateMessageResultWithTools = Infer; + +// @public +const CreateMessageResultWithToolsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + model: z.ZodString; + stopReason: z.ZodOptional, z.ZodString]>>; + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; +}, z.core.$loose>; + +// @public (undocumented) +export type CreateTaskResult = Infer; + +// @public +const CreateTaskResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + task: z.ZodObject<{ + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$loose>; + +// @public +export interface CrossAppAccessContext { + authorizationServerUrl: string; + fetchFn: FetchLike; + resourceUrl: string; + scope?: string; +} + +// @public +export class CrossAppAccessProvider implements OAuthClientProvider { + constructor(options: CrossAppAccessProviderOptions); + authorizationServerUrl?(): string | undefined; + // (undocumented) + clientInformation(): OAuthClientInformation; + // (undocumented) + get clientMetadata(): OAuthClientMetadata; + // (undocumented) + codeVerifier(): string; + // (undocumented) + prepareTokenRequest(scope?: string): Promise; + // (undocumented) + redirectToAuthorization(): void; + // (undocumented) + get redirectUrl(): undefined; + resourceUrl?(): string | undefined; + saveAuthorizationServerUrl?(authorizationServerUrl: string): void; + // (undocumented) + saveClientInformation(info: OAuthClientInformation): void; + // (undocumented) + saveCodeVerifier(): void; + saveResourceUrl?(resourceUrl: string): void; + // (undocumented) + saveTokens(tokens: OAuthTokens): void; + // (undocumented) + tokens(): OAuthTokens | undefined; +} + +// @public +export interface CrossAppAccessProviderOptions { + assertion: AssertionCallback; + clientId: string; + clientName?: string; + clientSecret: string; + fetchFn?: FetchLike; +} + +// @public (undocumented) +export type Cursor = Infer; + +// @public +const CursorSchema: z.ZodString; + +// @public (undocumented) +export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; + +// @public +export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60000; + +// @public +export interface DiscoverAndRequestJwtAuthGrantOptions extends Omit { + idpUrl: string | URL; +} + +// @public (undocumented) +export type DiscoverRequest = Infer; + +// @public +const DiscoverRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"server/discover">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type DiscoverResult = Infer; + +// @public +const DiscoverResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + supportedVersions: z.ZodArray; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + logging: z.ZodOptional>>; + completions: z.ZodOptional>>; + prompts: z.ZodOptional; + }, z.core.$strip>>; + resources: z.ZodOptional; + listChanged: z.ZodOptional; + }, z.core.$strip>>; + tools: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + serverInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + instructions: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type ElicitRequest = Infer; + +// @public (undocumented) +export type ElicitRequestFormParams = Infer; + +// @public +const ElicitRequestFormParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodOptional>; + message: z.ZodString; + requestedSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodRecord; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; + }, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>]>]>, z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>]>>; + required: z.ZodOptional>; + }, z.core.$catchall>; +}, z.core.$strip>; + +// @public (undocumented) +export type ElicitRequestParams = Infer; + +// @public +const ElicitRequestParamsSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodOptional>; + message: z.ZodString; + requestedSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodRecord; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; + }, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>]>]>, z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>]>>; + required: z.ZodOptional>; + }, z.core.$catchall>; +}, z.core.$strip>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodLiteral<"url">; + message: z.ZodString; + elicitationId: z.ZodString; + url: z.ZodString; +}, z.core.$strip>]>; + +// @public +const ElicitRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"elicitation/create">; + params: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodOptional>; + message: z.ZodString; + requestedSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodRecord; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; + }, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>]>]>, z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>]>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + }, z.core.$strip>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodLiteral<"url">; + message: z.ZodString; + elicitationId: z.ZodString; + url: z.ZodString; + }, z.core.$strip>]>; +}, z.core.$strip>; + +// @public (undocumented) +export type ElicitRequestURLParams = Infer; + +// @public +const ElicitRequestURLParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodLiteral<"url">; + message: z.ZodString; + elicitationId: z.ZodString; + url: z.ZodString; +}, z.core.$strip>; + +// @public (undocumented) +export type ElicitResult = Infer; + +// @public +const ElicitResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + action: z.ZodEnum<{ + cancel: "cancel"; + accept: "accept"; + decline: "decline"; + }>; + content: z.ZodPipe, z.ZodOptional]>>>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ElicitationCompleteNotification = Infer; + +// @public (undocumented) +export type ElicitationCompleteNotificationParams = Infer; + +// @public +const ElicitationCompleteNotificationParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + elicitationId: z.ZodString; +}, z.core.$strip>; + +// @public +const ElicitationCompleteNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/elicitation/complete">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + elicitationId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type EmbeddedResource = Infer; + +// @public +const EmbeddedResourceSchema: z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type EmptyResult = Infer; + +// @public +const EmptyResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$strict>; + +// @public (undocumented) +export type EnumSchema = Infer; + +// @public +const EnumSchemaSchema: z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; +}, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>]>]>; + +// @public +type ExpandRecursively = T extends object ? (T extends infer O ? { [K in keyof O]: ExpandRecursively } : never) : T; + +// @public (undocumented) +export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; + +// @public (undocumented) +type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; + +// @public (undocumented) +export type GetPromptRequest = Infer; + +// @public (undocumented) +export type GetPromptRequestParams = Infer; + +// @public +const GetPromptRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + name: z.ZodString; + arguments: z.ZodOptional>; +}, z.core.$strip>; + +// @public +const GetPromptRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"prompts/get">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + name: z.ZodString; + arguments: z.ZodOptional>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type GetPromptResult = Infer; + +// @public +const GetPromptResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + description: z.ZodOptional; + messages: z.ZodArray; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type GetTaskPayloadRequest = Infer; + +// @public +const GetTaskPayloadRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"tasks/result">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type GetTaskPayloadResult = Infer; + +// @public +const GetTaskPayloadResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type GetTaskRequest = Infer; + +// @public +const GetTaskRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"tasks/get">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type GetTaskResult = Infer; + +// @public +const GetTaskResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] }; + +// @public (undocumented) +export const INTERNAL_ERROR = -32603; + +// @public (undocumented) +export const INVALID_PARAMS = -32602; + +// @public (undocumented) +export const INVALID_REQUEST = -32600; + +// @public (undocumented) +export type Icon = Infer; + +// @public +const IconSchema: z.ZodObject<{ + src: z.ZodString; + mimeType: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type Icons = Infer; + +// @public +const IconsSchema: z.ZodObject<{ + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; +}, z.core.$strip>; + +// @public (undocumented) +export type ImageContent = Infer; + +// @public +const ImageContentSchema: z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type Implementation = Infer; + +// @public +const ImplementationSchema: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public +export class InMemoryTransport implements Transport { + // (undocumented) + close(): Promise; + static createLinkedPair(): [InMemoryTransport, InMemoryTransport]; + // (undocumented) + onclose?: () => void; + // (undocumented) + onerror?: (error: Error) => void; + // (undocumented) + onmessage?: (message: JSONRPCMessage, extra?: { + authInfo?: AuthInfo; + }) => void; + send(message: JSONRPCMessage, options?: { + relatedRequestId?: RequestId; + authInfo?: AuthInfo; + }): Promise; + // (undocumented) + sessionId?: string; + // (undocumented) + start(): Promise; +} + +// @public (undocumented) +type Infer = Flatten>; + +// @public (undocumented) +type InferHandlerResult = R extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : Result; + +// @public (undocumented) +export type InitializeRequest = Infer; + +// @public (undocumented) +export type InitializeRequestParams = Infer; + +// @public (undocumented) +const InitializeRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + protocolVersion: z.ZodString; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + sampling: z.ZodOptional>>; + tools: z.ZodOptional>>; + }, z.core.$strip>>; + elicitation: z.ZodOptional, z.ZodIntersection; + }, z.core.$strip>, z.ZodType>>>; + url: z.ZodOptional>>; + }, z.core.$strip>, z.ZodOptional>>>>>; + roots: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + elicitation: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + clientInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public +const InitializeRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"initialize">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + protocolVersion: z.ZodString; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + sampling: z.ZodOptional>>; + tools: z.ZodOptional>>; + }, z.core.$strip>>; + elicitation: z.ZodOptional, z.ZodIntersection; + }, z.core.$strip>, z.ZodType>>>; + url: z.ZodOptional>>; + }, z.core.$strip>, z.ZodOptional>>>>>; + roots: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + elicitation: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + clientInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type InitializeResult = Infer; + +// @public +const InitializeResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + protocolVersion: z.ZodString; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + logging: z.ZodOptional>>; + completions: z.ZodOptional>>; + prompts: z.ZodOptional; + }, z.core.$strip>>; + resources: z.ZodOptional; + listChanged: z.ZodOptional; + }, z.core.$strip>>; + tools: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + serverInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + instructions: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type InitializedNotification = Infer; + +// @public +const InitializedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/initialized">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export interface InternalError extends JSONRPCErrorObject { + // (undocumented) + code: typeof INTERNAL_ERROR; +} + +// @public (undocumented) +export interface InvalidParamsError extends JSONRPCErrorObject { + // (undocumented) + code: typeof INVALID_PARAMS; +} + +// @public (undocumented) +export interface InvalidRequestError extends JSONRPCErrorObject { + // (undocumented) + code: typeof INVALID_REQUEST; +} + +// @public (undocumented) +export type JSONArray = JSONValue[]; + +// @public (undocumented) +export type JSONObject = { + [key: string]: JSONValue; +}; + +// @public (undocumented) +type JSONRPCErrorObject = { + code: number; + message: string; + data?: unknown; +}; + +// @public (undocumented) +export type JSONRPCErrorResponse = Infer; + +// @public +const JSONRPCErrorResponseSchema: z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>; + +// @public (undocumented) +export type JSONRPCMessage = Infer; + +// @public (undocumented) +const JSONRPCMessageSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; +}, z.core.$strict>, z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>]>; + +// @public (undocumented) +export type JSONRPCNotification = Infer; + +// @public +const JSONRPCNotificationSchema: z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; +}, z.core.$strict>; + +// @public (undocumented) +export type JSONRPCRequest = Infer; + +// @public +const JSONRPCRequestSchema: z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; +}, z.core.$strict>; + +// @public (undocumented) +export type JSONRPCResponse = Infer; + +// @public (undocumented) +const JSONRPCResponseSchema: z.ZodUnion; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>]>; + +// @public (undocumented) +export type JSONRPCResultResponse = Infer; + +// @public +const JSONRPCResultResponseSchema: z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>; + +// @public (undocumented) +export const JSONRPC_VERSION = "2.0"; + +// @public (undocumented) +export type JSONValue = string | number | boolean | null | JSONObject | JSONArray; + +// @public +export type JsonSchemaType = JSONSchema.Interface; + +// @public +export type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +export type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public +export interface JwtAuthGrantResult { + expiresIn?: number; + jwtAuthGrant: string; + scope?: string; +} + +// @public (undocumented) +export const LATEST_PROTOCOL_VERSION = "2025-11-25"; + +// @public @deprecated +export const LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel"; + +// @public (undocumented) +export type LegacyTitledEnumSchema = Infer; + +// @public +const LegacyTitledEnumSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public +export type ListChangedCallback = (error: Error | null, items: T[] | null) => void; + +// @public +export type ListChangedHandlers = { + tools?: ListChangedOptions; + prompts?: ListChangedOptions; + resources?: ListChangedOptions; +}; + +// @public +export type ListChangedOptions = { + autoRefresh?: boolean; + debounceMs?: number; + onChanged: ListChangedCallback; +}; + +// @public (undocumented) +export type ListPromptsRequest = Infer; + +// @public +const ListPromptsRequestSchema: z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"prompts/list">; +}, z.core.$strip>; + +// @public (undocumented) +export type ListPromptsResult = Infer; + +// @public +const ListPromptsResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + prompts: z.ZodArray; + arguments: z.ZodOptional; + required: z.ZodOptional; + }, z.core.$strip>>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ListResourceTemplatesRequest = Infer; + +// @public +const ListResourceTemplatesRequestSchema: z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"resources/templates/list">; +}, z.core.$strip>; + +// @public (undocumented) +export type ListResourceTemplatesResult = Infer; + +// @public +const ListResourceTemplatesResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + resourceTemplates: z.ZodArray; + mimeType: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ListResourcesRequest = Infer; + +// @public +const ListResourcesRequestSchema: z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"resources/list">; +}, z.core.$strip>; + +// @public (undocumented) +export type ListResourcesResult = Infer; + +// @public +const ListResourcesResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + resources: z.ZodArray; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ListRootsRequest = Infer; + +// @public +const ListRootsRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"roots/list">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type ListRootsResult = Infer; + +// @public +const ListRootsResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + roots: z.ZodArray; + _meta: z.ZodOptional>; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ListTasksRequest = Infer; + +// @public +const ListTasksRequestSchema: z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"tasks/list">; +}, z.core.$strip>; + +// @public (undocumented) +export type ListTasksResult = Infer; + +// @public +const ListTasksResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + tasks: z.ZodArray; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ListToolsRequest = Infer; + +// @public +const ListToolsRequestSchema: z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"tools/list">; +}, z.core.$strip>; + +// @public (undocumented) +export type ListToolsResult = Infer; + +// @public +const ListToolsResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + tools: z.ZodArray; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type LoggingLevel = Infer; + +// @public +const LoggingLevelSchema: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; +}>; + +// @public (undocumented) +export type LoggingMessageNotification = Infer; + +// @public (undocumented) +export type LoggingMessageNotificationParams = Infer; + +// @public +const LoggingMessageNotificationParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; + logger: z.ZodOptional; + data: z.ZodUnknown; +}, z.core.$strip>; + +// @public +const LoggingMessageNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/message">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; + logger: z.ZodOptional; + data: z.ZodUnknown; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public +export type LoggingOptions = { + logger?: RequestLogger; + includeRequestHeaders?: boolean; + includeResponseHeaders?: boolean; + statusLevel?: number; +}; + +// @public (undocumented) +export const METHOD_NOT_FOUND = -32601; + +// @public +export interface MessageExtraInfo { + authInfo?: AuthInfo; + closeSSEStream?: () => void; + closeStandaloneSSEStream?: () => void; + request?: globalThis.Request; +} + +// @public (undocumented) +export type MetaObject = Record; + +// @public (undocumented) +export interface MethodNotFoundError extends JSONRPCErrorObject { + // (undocumented) + code: typeof METHOD_NOT_FOUND; +} + +// @public (undocumented) +type MethodToTypeMap = { [T in U as T extends { + method: infer M extends string; + } ? M : never]: T }; + +// @public +export type Middleware = (next: FetchLike) => FetchLike; + +// @public (undocumented) +export type ModelHint = Infer; + +// @public +const ModelHintSchema: z.ZodObject<{ + name: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ModelPreferences = Infer; + +// @public +const ModelPreferencesSchema: z.ZodObject<{ + hints: z.ZodOptional; + }, z.core.$strip>>>; + costPriority: z.ZodOptional; + speedPriority: z.ZodOptional; + intelligencePriority: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type MultiSelectEnumSchema = Infer; + +// @public +const MultiSelectEnumSchemaSchema: z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type NotificationMethod = ClientNotification['method'] | ServerNotification['method']; + +// @public +type NotificationOptions_2 = { + relatedRequestId?: RequestId; +}; +export { NotificationOptions_2 as NotificationOptions } + +// @public (undocumented) +export type NotificationParams = Infer; + +// @public (undocumented) +const NotificationSchema: z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; +}, z.core.$strip>; + +// @public (undocumented) +export type NotificationTypeMap = MethodToTypeMap; + +// @public (undocumented) +type Notification_2 = Infer; +export { Notification_2 as Notification } + +// @public (undocumented) +const NotificationsParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; +}, z.core.$strip>; + +// @public (undocumented) +export type NumberSchema = Infer; + +// @public +const NumberSchemaSchema: z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthClientInformation = z.infer; + +// @public (undocumented) +export type OAuthClientInformationFull = z.infer; + +// @public +const OAuthClientInformationFullSchema: z.ZodObject<{ + redirect_uris: z.ZodArray; + token_endpoint_auth_method: z.ZodOptional; + grant_types: z.ZodOptional>; + response_types: z.ZodOptional>; + client_name: z.ZodOptional; + client_uri: z.ZodOptional; + logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + scope: z.ZodOptional; + contacts: z.ZodOptional>; + tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + policy_uri: z.ZodOptional; + jwks_uri: z.ZodOptional; + jwks: z.ZodOptional; + software_id: z.ZodOptional; + software_version: z.ZodOptional; + software_statement: z.ZodOptional; + client_id: z.ZodString; + client_secret: z.ZodOptional; + client_id_issued_at: z.ZodOptional; + client_secret_expires_at: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthClientInformationMixed = OAuthClientInformation | OAuthClientInformationFull; + +// @public +const OAuthClientInformationSchema: z.ZodObject<{ + client_id: z.ZodString; + client_secret: z.ZodOptional; + client_id_issued_at: z.ZodOptional; + client_secret_expires_at: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthClientMetadata = z.infer; + +// @public +const OAuthClientMetadataSchema: z.ZodObject<{ + redirect_uris: z.ZodArray; + token_endpoint_auth_method: z.ZodOptional; + grant_types: z.ZodOptional>; + response_types: z.ZodOptional>; + client_name: z.ZodOptional; + client_uri: z.ZodOptional; + logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + scope: z.ZodOptional; + contacts: z.ZodOptional>; + tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + policy_uri: z.ZodOptional; + jwks_uri: z.ZodOptional; + jwks: z.ZodOptional; + software_id: z.ZodOptional; + software_version: z.ZodOptional; + software_statement: z.ZodOptional; +}, z.core.$strip>; + +// @public +export interface OAuthClientProvider { + addClientAuthentication?: AddClientAuthentication; + authorizationServerUrl?(): string | undefined | Promise; + clientInformation(): OAuthClientInformationMixed | undefined | Promise; + get clientMetadata(): OAuthClientMetadata; + clientMetadataUrl?: string; + codeVerifier(): string | Promise; + discoveryState?(): OAuthDiscoveryState | undefined | Promise; + invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise; + prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + redirectToAuthorization(authorizationUrl: URL): void | Promise; + get redirectUrl(): string | URL | undefined; + resourceUrl?(): string | undefined | Promise; + saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise; + saveClientInformation?(clientInformation: OAuthClientInformationMixed): void | Promise; + saveCodeVerifier(codeVerifier: string): void | Promise; + saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; + saveResourceUrl?(resourceUrl: string): void | Promise; + saveTokens(tokens: OAuthTokens): void | Promise; + state?(): string | Promise; + tokens(): OAuthTokens | undefined | Promise; + validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; +} + +// @public (undocumented) +export type OAuthClientRegistrationError = z.infer; + +// @public +const OAuthClientRegistrationErrorSchema: z.ZodObject<{ + error: z.ZodString; + error_description: z.ZodOptional; +}, z.core.$strip>; + +// @public +export interface OAuthDiscoveryState extends OAuthServerInfo { + resourceMetadataUrl?: string; +} + +// @public +export class OAuthError extends Error { + constructor(code: OAuthErrorCode | string, message: string, errorUri?: string | undefined); + // (undocumented) + readonly code: OAuthErrorCode | string; + // (undocumented) + readonly errorUri?: string | undefined; + static fromResponse(response: OAuthErrorResponse): OAuthError; + toResponseObject(): OAuthErrorResponse; +} + +// @public +export enum OAuthErrorCode { + AccessDenied = "access_denied", + InsufficientScope = "insufficient_scope", + InvalidClient = "invalid_client", + InvalidClientMetadata = "invalid_client_metadata", + InvalidGrant = "invalid_grant", + InvalidRequest = "invalid_request", + InvalidScope = "invalid_scope", + InvalidTarget = "invalid_target", + InvalidToken = "invalid_token", + MethodNotAllowed = "method_not_allowed", + ServerError = "server_error", + TemporarilyUnavailable = "temporarily_unavailable", + TooManyRequests = "too_many_requests", + UnauthorizedClient = "unauthorized_client", + UnsupportedGrantType = "unsupported_grant_type", + UnsupportedResponseType = "unsupported_response_type", + UnsupportedTokenType = "unsupported_token_type", +} + +// @public (undocumented) +export type OAuthErrorResponse = z.infer; + +// @public +const OAuthErrorResponseSchema: z.ZodObject<{ + error: z.ZodString; + error_description: z.ZodOptional; + error_uri: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthMetadata = z.infer; + +// @public +const OAuthMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + revocation_endpoint: z.ZodOptional; + revocation_endpoint_auth_methods_supported: z.ZodOptional>; + revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + introspection_endpoint: z.ZodOptional; + introspection_endpoint_auth_methods_supported: z.ZodOptional>; + introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + code_challenge_methods_supported: z.ZodOptional>; + client_id_metadata_document_supported: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type OAuthProtectedResourceMetadata = z.infer; + +// @public +const OAuthProtectedResourceMetadataSchema: z.ZodObject<{ + resource: z.ZodString; + authorization_servers: z.ZodOptional>; + jwks_uri: z.ZodOptional; + scopes_supported: z.ZodOptional>; + bearer_methods_supported: z.ZodOptional>; + resource_signing_alg_values_supported: z.ZodOptional>; + resource_name: z.ZodOptional; + resource_documentation: z.ZodOptional; + resource_policy_uri: z.ZodOptional; + resource_tos_uri: z.ZodOptional; + tls_client_certificate_bound_access_tokens: z.ZodOptional; + authorization_details_types_supported: z.ZodOptional>; + dpop_signing_alg_values_supported: z.ZodOptional>; + dpop_bound_access_tokens_required: z.ZodOptional; +}, z.core.$loose>; + +// @public +export interface OAuthServerInfo { + authorizationServerMetadata?: AuthorizationServerMetadata; + authorizationServerUrl: string; + resourceMetadata?: OAuthProtectedResourceMetadata; +} + +// @public (undocumented) +export type OAuthTokenRevocationRequest = z.infer; + +// @public +const OAuthTokenRevocationRequestSchema: z.ZodObject<{ + token: z.ZodString; + token_type_hint: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthTokens = z.infer; + +// @public +const OAuthTokensSchema: z.ZodObject<{ + access_token: z.ZodString; + id_token: z.ZodOptional; + token_type: z.ZodString; + expires_in: z.ZodOptional>; + scope: z.ZodOptional; + refresh_token: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OpenIdProviderDiscoveryMetadata = z.infer; + +// @public +const OpenIdProviderDiscoveryMetadataSchema: z.ZodObject<{ + code_challenge_methods_supported: z.ZodOptional>; + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + userinfo_endpoint: z.ZodOptional; + jwks_uri: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + acr_values_supported: z.ZodOptional>; + subject_types_supported: z.ZodArray; + id_token_signing_alg_values_supported: z.ZodArray; + id_token_encryption_alg_values_supported: z.ZodOptional>; + id_token_encryption_enc_values_supported: z.ZodOptional>; + userinfo_signing_alg_values_supported: z.ZodOptional>; + userinfo_encryption_alg_values_supported: z.ZodOptional>; + userinfo_encryption_enc_values_supported: z.ZodOptional>; + request_object_signing_alg_values_supported: z.ZodOptional>; + request_object_encryption_alg_values_supported: z.ZodOptional>; + request_object_encryption_enc_values_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + display_values_supported: z.ZodOptional>; + claim_types_supported: z.ZodOptional>; + claims_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + claims_locales_supported: z.ZodOptional>; + ui_locales_supported: z.ZodOptional>; + claims_parameter_supported: z.ZodOptional; + request_parameter_supported: z.ZodOptional; + request_uri_parameter_supported: z.ZodOptional; + require_request_uri_registration: z.ZodOptional; + op_policy_uri: z.ZodOptional; + op_tos_uri: z.ZodOptional; + client_id_metadata_document_supported: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OpenIdProviderMetadata = z.infer; + +// @public +const OpenIdProviderMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + userinfo_endpoint: z.ZodOptional; + jwks_uri: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + acr_values_supported: z.ZodOptional>; + subject_types_supported: z.ZodArray; + id_token_signing_alg_values_supported: z.ZodArray; + id_token_encryption_alg_values_supported: z.ZodOptional>; + id_token_encryption_enc_values_supported: z.ZodOptional>; + userinfo_signing_alg_values_supported: z.ZodOptional>; + userinfo_encryption_alg_values_supported: z.ZodOptional>; + userinfo_encryption_enc_values_supported: z.ZodOptional>; + request_object_signing_alg_values_supported: z.ZodOptional>; + request_object_encryption_alg_values_supported: z.ZodOptional>; + request_object_encryption_enc_values_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + display_values_supported: z.ZodOptional>; + claim_types_supported: z.ZodOptional>; + claims_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + claims_locales_supported: z.ZodOptional>; + ui_locales_supported: z.ZodOptional>; + claims_parameter_supported: z.ZodOptional; + request_parameter_supported: z.ZodOptional; + request_uri_parameter_supported: z.ZodOptional; + require_request_uri_registration: z.ZodOptional; + op_policy_uri: z.ZodOptional; + op_tos_uri: z.ZodOptional; + client_id_metadata_document_supported: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export const PARSE_ERROR = -32700; + +// @public +export const PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"; + +// @public (undocumented) +export type PaginatedRequest = Infer; + +// @public (undocumented) +export type PaginatedRequestParams = Infer; + +// @public (undocumented) +const PaginatedRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +const PaginatedRequestSchema: z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type PaginatedResult = Infer; + +// @public (undocumented) +const PaginatedResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export interface ParseError extends JSONRPCErrorObject { + // (undocumented) + code: typeof PARSE_ERROR; +} + +// @public (undocumented) +export type PingRequest = Infer; + +// @public +const PingRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"ping">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +type Primitive = string | number | boolean | bigint | null | undefined; + +// @public (undocumented) +export type PrimitiveSchemaDefinition = Infer; + +// @public +const PrimitiveSchemaDefinitionSchema: z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; +}, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>]>]>, z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; +}, z.core.$strip>]>; + +// @public +export class PrivateKeyJwtProvider implements OAuthClientProvider { + constructor(options: PrivateKeyJwtProviderOptions); + // (undocumented) + addClientAuthentication: AddClientAuthentication; + // (undocumented) + clientInformation(): OAuthClientInformation; + // (undocumented) + get clientMetadata(): OAuthClientMetadata; + // (undocumented) + codeVerifier(): string; + // (undocumented) + prepareTokenRequest(scope?: string): URLSearchParams; + // (undocumented) + redirectToAuthorization(): void; + // (undocumented) + get redirectUrl(): undefined; + // (undocumented) + saveClientInformation(info: OAuthClientInformation): void; + // (undocumented) + saveCodeVerifier(): void; + // (undocumented) + saveTokens(tokens: OAuthTokens): void; + // (undocumented) + tokens(): OAuthTokens | undefined; +} + +// @public +export interface PrivateKeyJwtProviderOptions { + algorithm: string; + claims?: Record; + clientId: string; + clientName?: string; + jwtLifetimeSeconds?: number; + privateKey: string | Uint8Array | Record; + scope?: string; +} + +// @public (undocumented) +export type Progress = Infer; + +// @public +export type ProgressCallback = (progress: Progress) => void; + +// @public (undocumented) +export type ProgressNotification = Infer; + +// @public (undocumented) +export type ProgressNotificationParams = Infer; + +// @public (undocumented) +const ProgressNotificationParamsSchema: z.ZodObject<{ + progressToken: z.ZodUnion; + progress: z.ZodNumber; + total: z.ZodOptional; + message: z.ZodOptional; + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; +}, z.core.$strip>; + +// @public +const ProgressNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/progress">; + params: z.ZodObject<{ + progressToken: z.ZodUnion; + progress: z.ZodNumber; + total: z.ZodOptional; + message: z.ZodOptional; + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +const ProgressSchema: z.ZodObject<{ + progress: z.ZodNumber; + total: z.ZodOptional; + message: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ProgressToken = Infer; + +// @public +const ProgressTokenSchema: z.ZodUnion; + +// @public (undocumented) +export type Prompt = Infer; + +// @public (undocumented) +export type PromptArgument = Infer; + +// @public +const PromptArgumentSchema: z.ZodObject<{ + name: z.ZodString; + description: z.ZodOptional; + required: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type PromptListChangedNotification = Infer; + +// @public +const PromptListChangedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/prompts/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type PromptMessage = Infer; + +// @public +const PromptMessageSchema: z.ZodObject<{ + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>; +}, z.core.$strip>; + +// @public (undocumented) +export type PromptReference = Infer; + +// @public +const PromptReferenceSchema: z.ZodObject<{ + type: z.ZodLiteral<"ref/prompt">; + name: z.ZodString; +}, z.core.$strip>; + +// @public +const PromptSchema: z.ZodObject<{ + description: z.ZodOptional; + arguments: z.ZodOptional; + required: z.ZodOptional; + }, z.core.$strip>>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public +abstract class Protocol { + constructor(_options?: ProtocolOptions | undefined); + assertCanSetRequestHandler(method: RequestMethod | string): void; + protected abstract assertCapabilityForMethod(method: RequestMethod | string): void; + protected abstract assertNotificationCapability(method: NotificationMethod | string): void; + protected abstract assertRequestHandlerCapability(method: string): void; + protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; + close(): Promise; + connect(transport: Transport): Promise; + fallbackNotificationHandler?: (notification: Notification_2) => Promise; + fallbackRequestHandler?: (request: JSONRPCRequest, ctx: ContextT) => Promise; + notification(notification: Notification_2, options?: NotificationOptions_2): Promise; + onclose?: () => void; + onerror?: (error: Error) => void; + removeNotificationHandler(method: NotificationMethod | string): void; + removeRequestHandler(method: RequestMethod | string): void; + request(request: { + method: M; + params?: Record; + }, options?: RequestOptions): Promise; + // (undocumented) + request(request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; + protected _requestWithSchema(request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; + setNotificationHandler(method: M, handler: (notification: NotificationTypeMap[M]) => void | Promise): void; + // (undocumented) + setNotificationHandler

(method: string, schemas: { + params: P; + }, handler: (params: StandardSchemaV1.InferOutput

, notification: Notification_2) => void | Promise): void; + setRequestHandler(method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => ResultTypeMap[M] | Promise): void; + // (undocumented) + setRequestHandler

(method: string, schemas: { + params: P; + result?: R; + }, handler: (params: StandardSchemaV1.InferOutput

, ctx: ContextT) => InferHandlerResult | Promise>): void; + // (undocumented) + protected _supportedProtocolVersions: string[]; + // (undocumented) + get transport(): Transport | undefined; + protected _wrapHandler(_method: string, handler: (request: JSONRPCRequest, ctx: ContextT) => Promise): (request: JSONRPCRequest, ctx: ContextT) => Promise; +} + +// @public +export class ProtocolError extends Error { + constructor(code: number, message: string, data?: unknown | undefined); + // (undocumented) + readonly code: number; + // (undocumented) + readonly data?: unknown | undefined; + static fromError(code: number, message: string, data?: unknown): ProtocolError; +} + +// @public +export enum ProtocolErrorCode { + // (undocumented) + InternalError = -32603, + // (undocumented) + InvalidParams = -32602, + // (undocumented) + InvalidRequest = -32600, + // (undocumented) + MethodNotFound = -32601, + MissingRequiredClientCapability = -32003, + // (undocumented) + ParseError = -32700, + // (undocumented) + ResourceNotFound = -32002, + UnsupportedProtocolVersion = -32004, + // (undocumented) + UrlElicitationRequired = -32042, +} + +// @public +export type ProtocolOptions = { + supportedProtocolVersions?: string[]; + enforceStrictCapabilities?: boolean; + debouncedNotificationMethods?: string[]; +}; + +// @public (undocumented) +type ProtocolSchemaKey = (typeof SPEC_SCHEMA_KEYS)[number]; + +// @public (undocumented) +export const RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; + +// @public +export class ReadBuffer { + constructor(options?: { + maxBufferSize?: number; + }); + // (undocumented) + append(chunk: Buffer): void; + // (undocumented) + clear(): void; + // (undocumented) + readMessage(): JSONRPCMessage | null; +} + +// @public (undocumented) +export type ReadResourceRequest = Infer; + +// @public (undocumented) +export type ReadResourceRequestParams = Infer; + +// @public +const ReadResourceRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const ReadResourceRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"resources/read">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type ReadResourceResult = Infer; + +// @public +const ReadResourceResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + contents: z.ZodArray; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>>; +}, z.core.$loose>; + +// @public +export type ReconnectionScheduler = (reconnect: () => void, delay: number, attemptCount: number) => (() => void) | void; + +// @public (undocumented) +export type RelatedTaskMetadata = Infer; + +// @public +const RelatedTaskMetadataSchema: z.ZodObject<{ + taskId: z.ZodString; +}, z.core.$strip>; + +// @public +export interface RequestHandlerSchemas

{ + // (undocumented) + params: P; + // (undocumented) + result?: R; +} + +// @public (undocumented) +export type RequestId = Infer; + +// @public +const RequestIdSchema: z.ZodUnion; + +// @public +export interface RequestJwtAuthGrantOptions { + audience: string | URL; + clientId: string; + clientSecret?: string; + fetchFn?: FetchLike; + idToken: string; + resource: string | URL; + scope?: string; + tokenEndpoint: string | URL; +} + +// @public +export type RequestLogger = (input: { + method: string; + url: string | URL; + status: number; + statusText: string; + duration: number; + requestHeaders?: Headers; + responseHeaders?: Headers; + error?: Error; +}) => void; + +// @public (undocumented) +export type RequestMeta = Infer; + +// @public +export type RequestMetaEnvelope = Infer; + +// @public +const RequestMetaEnvelopeSchema: z.ZodObject<{ + progressToken: z.ZodOptional>; + "io.modelcontextprotocol/protocolVersion": z.ZodString; + "io.modelcontextprotocol/clientInfo": z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + "io.modelcontextprotocol/clientCapabilities": z.ZodObject<{ + experimental: z.ZodOptional>>>; + sampling: z.ZodOptional>>; + tools: z.ZodOptional>>; + }, z.core.$strip>>; + elicitation: z.ZodOptional, z.ZodIntersection; + }, z.core.$strip>, z.ZodType>>>; + url: z.ZodOptional>>; + }, z.core.$strip>, z.ZodOptional>>>>>; + roots: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + elicitation: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + "io.modelcontextprotocol/logLevel": z.ZodOptional>; +}, z.core.$loose>; + +// @public (undocumented) +export type RequestMetaObject = RequestMeta; + +// @public (undocumented) +const RequestMetaSchema: z.ZodObject<{ + progressToken: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; +}, z.core.$loose>; + +// @public (undocumented) +export type RequestMethod = ClientRequest['method'] | ServerRequest['method']; + +// @public +export type RequestOptions = { + onprogress?: ProgressCallback; + signal?: AbortSignal; + timeout?: number; + resetTimeoutOnProgress?: boolean; + maxTotalTimeout?: number; +} & TransportSendOptions; + +// @public (undocumented) +export type RequestParams = Infer; + +// @public (undocumented) +const RequestSchema: z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; +}, z.core.$strip>; + +// @public (undocumented) +export type RequestTypeMap = MethodToTypeMap; + +// @public (undocumented) +type Request_2 = Infer; +export { Request_2 as Request } + +// @public (undocumented) +export type Resource = Infer; + +// @public (undocumented) +export type ResourceContents = Infer; + +// @public +const ResourceContentsSchema: z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type ResourceLink = Infer; + +// @public +const ResourceLinkSchema: z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; +}, z.core.$strip>; + +// @public (undocumented) +export type ResourceListChangedNotification = Infer; + +// @public +const ResourceListChangedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/resources/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type ResourceRequestParams = Infer; + +// @public (undocumented) +const ResourceRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const ResourceSchema: z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ResourceTemplateReference = Infer; + +// @public +const ResourceTemplateReferenceSchema: z.ZodObject<{ + type: z.ZodLiteral<"ref/resource">; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const ResourceTemplateSchema: z.ZodObject<{ + uriTemplate: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ResourceTemplateType = Infer; + +// @public (undocumented) +export type ResourceUpdatedNotification = Infer; + +// @public (undocumented) +export type ResourceUpdatedNotificationParams = Infer; + +// @public +const ResourceUpdatedNotificationParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const ResourceUpdatedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/resources/updated">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type Result = Infer; + +// @public (undocumented) +const ResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type ResultTypeMap = { + ping: EmptyResult; + initialize: InitializeResult; + 'completion/complete': CompleteResult; + 'logging/setLevel': EmptyResult; + 'prompts/get': GetPromptResult; + 'prompts/list': ListPromptsResult; + 'resources/list': ListResourcesResult; + 'resources/templates/list': ListResourceTemplatesResult; + 'resources/read': ReadResourceResult; + 'resources/subscribe': EmptyResult; + 'resources/unsubscribe': EmptyResult; + 'tools/call': CallToolResult | CreateTaskResult; + 'tools/list': ListToolsResult; + 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools | CreateTaskResult; + 'elicitation/create': ElicitResult | CreateTaskResult; + 'roots/list': ListRootsResult; + 'tasks/get': GetTaskResult; + 'tasks/result': Result; + 'tasks/list': ListTasksResult; + 'tasks/cancel': CancelTaskResult; +}; + +// @public (undocumented) +export type Role = Infer; + +// @public +const RoleSchema: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; +}>; + +// @public (undocumented) +export type Root = Infer; + +// @public +const RootSchema: z.ZodObject<{ + uri: z.ZodString; + name: z.ZodOptional; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type RootsListChangedNotification = Infer; + +// @public +const RootsListChangedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/roots/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public +const SPEC_SCHEMA_KEYS: readonly ["AnnotationsSchema", "AudioContentSchema", "BaseMetadataSchema", "BlobResourceContentsSchema", "BooleanSchemaSchema", "CallToolRequestSchema", "CallToolRequestParamsSchema", "CallToolResultSchema", "CancelledNotificationSchema", "CancelledNotificationParamsSchema", "CancelTaskRequestSchema", "CancelTaskResultSchema", "ClientCapabilitiesSchema", "ClientNotificationSchema", "ClientRequestSchema", "ClientResultSchema", "CompatibilityCallToolResultSchema", "CompleteRequestSchema", "CompleteRequestParamsSchema", "CompleteResultSchema", "ContentBlockSchema", "CreateMessageRequestSchema", "CreateMessageRequestParamsSchema", "CreateMessageResultSchema", "CreateMessageResultWithToolsSchema", "CreateTaskResultSchema", "CursorSchema", "DiscoverRequestSchema", "DiscoverResultSchema", "ElicitationCompleteNotificationSchema", "ElicitationCompleteNotificationParamsSchema", "ElicitRequestSchema", "ElicitRequestFormParamsSchema", "ElicitRequestParamsSchema", "ElicitRequestURLParamsSchema", "ElicitResultSchema", "EmbeddedResourceSchema", "EmptyResultSchema", "EnumSchemaSchema", "GetPromptRequestSchema", "GetPromptRequestParamsSchema", "GetPromptResultSchema", "GetTaskPayloadRequestSchema", "GetTaskPayloadResultSchema", "GetTaskRequestSchema", "GetTaskResultSchema", "IconSchema", "IconsSchema", "ImageContentSchema", "ImplementationSchema", "InitializedNotificationSchema", "InitializeRequestSchema", "InitializeRequestParamsSchema", "InitializeResultSchema", "JSONArraySchema", "JSONObjectSchema", "JSONRPCErrorResponseSchema", "JSONRPCMessageSchema", "JSONRPCNotificationSchema", "JSONRPCRequestSchema", "JSONRPCResponseSchema", "JSONRPCResultResponseSchema", "JSONValueSchema", "LegacyTitledEnumSchemaSchema", "ListPromptsRequestSchema", "ListPromptsResultSchema", "ListResourcesRequestSchema", "ListResourcesResultSchema", "ListResourceTemplatesRequestSchema", "ListResourceTemplatesResultSchema", "ListRootsRequestSchema", "ListRootsResultSchema", "ListTasksRequestSchema", "ListTasksResultSchema", "ListToolsRequestSchema", "ListToolsResultSchema", "LoggingLevelSchema", "LoggingMessageNotificationSchema", "LoggingMessageNotificationParamsSchema", "ModelHintSchema", "ModelPreferencesSchema", "MultiSelectEnumSchemaSchema", "NotificationSchema", "NumberSchemaSchema", "PaginatedRequestSchema", "PaginatedRequestParamsSchema", "PaginatedResultSchema", "PingRequestSchema", "PrimitiveSchemaDefinitionSchema", "ProgressSchema", "ProgressNotificationSchema", "ProgressNotificationParamsSchema", "ProgressTokenSchema", "PromptSchema", "PromptArgumentSchema", "PromptListChangedNotificationSchema", "PromptMessageSchema", "PromptReferenceSchema", "ReadResourceRequestSchema", "ReadResourceRequestParamsSchema", "ReadResourceResultSchema", "RelatedTaskMetadataSchema", "RequestSchema", "RequestIdSchema", "RequestMetaEnvelopeSchema", "RequestMetaSchema", "ResourceSchema", "ResourceContentsSchema", "ResourceLinkSchema", "ResourceListChangedNotificationSchema", "ResourceRequestParamsSchema", "ResourceTemplateSchema", "ResourceTemplateReferenceSchema", "ResourceUpdatedNotificationSchema", "ResourceUpdatedNotificationParamsSchema", "ResultSchema", "RoleSchema", "RootSchema", "RootsListChangedNotificationSchema", "SamplingContentSchema", "SamplingMessageSchema", "SamplingMessageContentBlockSchema", "ServerCapabilitiesSchema", "ServerNotificationSchema", "ServerRequestSchema", "ServerResultSchema", "SetLevelRequestSchema", "SetLevelRequestParamsSchema", "SingleSelectEnumSchemaSchema", "StringSchemaSchema", "SubscribeRequestSchema", "SubscribeRequestParamsSchema", "TaskSchema", "TaskAugmentedRequestParamsSchema", "TaskCreationParamsSchema", "TaskMetadataSchema", "TaskStatusSchema", "TaskStatusNotificationSchema", "TaskStatusNotificationParamsSchema", "TextContentSchema", "TextResourceContentsSchema", "TitledMultiSelectEnumSchemaSchema", "TitledSingleSelectEnumSchemaSchema", "ToolSchema", "ToolAnnotationsSchema", "ToolChoiceSchema", "ToolExecutionSchema", "ToolListChangedNotificationSchema", "ToolResultContentSchema", "ToolUseContentSchema", "UnsubscribeRequestSchema", "UnsubscribeRequestParamsSchema", "UntitledMultiSelectEnumSchemaSchema", "UntitledSingleSelectEnumSchemaSchema"]; + +// @public @deprecated +export class SSEClientTransport implements Transport { + constructor(url: URL, opts?: SSEClientTransportOptions); + // (undocumented) + close(): Promise; + finishAuth(authorizationCode: string): Promise; + // (undocumented) + onclose?: () => void; + // (undocumented) + onerror?: (error: Error) => void; + // (undocumented) + onmessage?: (message: JSONRPCMessage) => void; + // (undocumented) + send(message: JSONRPCMessage): Promise; + // (undocumented) + setProtocolVersion(version: string): void; + // (undocumented) + start(): Promise; +} + +// @public +export type SSEClientTransportOptions = { + authProvider?: AuthProvider | OAuthClientProvider; + eventSourceInit?: EventSourceInit_2; + requestInit?: RequestInit; + fetch?: FetchLike; +}; + +// @public (undocumented) +export const STDIO_DEFAULT_MAX_BUFFER_SIZE: number; + +// @public (undocumented) +export const SUPPORTED_PROTOCOL_VERSIONS: string[]; + +// @public (undocumented) +export type SamplingContent = Infer; + +// @public +const SamplingContentSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"text">; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>], "type">; + +// @public (undocumented) +export type SamplingMessage = Infer; + +// @public (undocumented) +export type SamplingMessageContentBlock = Infer; + +// @public +const SamplingMessageContentBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"text">; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; +}, z.core.$strip>], "type">; + +// @public +const SamplingMessageSchema: z.ZodObject<{ + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +type SchemaFor = K extends ProtocolSchemaKey ? (typeof schemas_d_exports)[K] : K extends AuthSchemaKey ? (typeof authSchemas)[K] : never; + +// @public (undocumented) +type SchemaKey = ProtocolSchemaKey | AuthSchemaKey; + +// @public (undocumented) +type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync }; + +// @public +export class SdkError extends Error { + constructor(code: SdkErrorCode, message: string, data?: unknown | undefined); + // (undocumented) + readonly code: SdkErrorCode; + // (undocumented) + readonly data?: unknown | undefined; +} + +// @public +export enum SdkErrorCode { + AlreadyConnected = "ALREADY_CONNECTED", + CapabilityNotSupported = "CAPABILITY_NOT_SUPPORTED", + // (undocumented) + ClientHttpAuthentication = "CLIENT_HTTP_AUTHENTICATION", + // (undocumented) + ClientHttpFailedToOpenStream = "CLIENT_HTTP_FAILED_TO_OPEN_STREAM", + // (undocumented) + ClientHttpFailedToTerminateSession = "CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION", + // (undocumented) + ClientHttpForbidden = "CLIENT_HTTP_FORBIDDEN", + // (undocumented) + ClientHttpNotImplemented = "CLIENT_HTTP_NOT_IMPLEMENTED", + // (undocumented) + ClientHttpUnexpectedContent = "CLIENT_HTTP_UNEXPECTED_CONTENT", + ConnectionClosed = "CONNECTION_CLOSED", + InvalidResult = "INVALID_RESULT", + NotConnected = "NOT_CONNECTED", + NotInitialized = "NOT_INITIALIZED", + RequestTimeout = "REQUEST_TIMEOUT", + SendFailed = "SEND_FAILED", +} + +// @public +export class SdkHttpError extends SdkError { + constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData); + // (undocumented) + readonly data: SdkHttpErrorData; + // (undocumented) + get status(): number; + // (undocumented) + get statusText(): string | undefined; +} + +// @public +export interface SdkHttpErrorData { + // (undocumented) + [key: string]: unknown; + // (undocumented) + status: number; + // (undocumented) + statusText?: string; +} + +// @public (undocumented) +export type ServerCapabilities = Infer; + +// @public +const ServerCapabilitiesSchema: z.ZodObject<{ + experimental: z.ZodOptional>>>; + logging: z.ZodOptional>>; + completions: z.ZodOptional>>; + prompts: z.ZodOptional; + }, z.core.$strip>>; + resources: z.ZodOptional; + listChanged: z.ZodOptional; + }, z.core.$strip>>; + tools: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; +}, z.core.$strip>; + +// @public +export type ServerContext = BaseContext & { + mcpReq: { + log: (level: LoggingLevel, data: unknown, logger?: string) => Promise; + elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; + requestSampling: (params: CreateMessageRequest['params'], options?: RequestOptions) => Promise; + }; + http?: { + req?: globalThis.Request; + closeSSE?: () => void; + closeStandaloneSSE?: () => void; + }; +}; + +// @public (undocumented) +export type ServerNotification = Infer; + +// @public (undocumented) +const ServerNotificationSchema: z.ZodUnion; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + requestId: z.ZodOptional>; + reason: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/progress">; + params: z.ZodObject<{ + progressToken: z.ZodUnion; + progress: z.ZodNumber; + total: z.ZodOptional; + message: z.ZodOptional; + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/message">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; + logger: z.ZodOptional; + data: z.ZodUnknown; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/resources/updated">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/resources/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/tools/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/prompts/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/tasks/status">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/elicitation/complete">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + elicitationId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type ServerRequest = Infer; + +// @public (undocumented) +const ServerRequestSchema: z.ZodUnion; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"sampling/createMessage">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + messages: z.ZodArray; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; + _meta: z.ZodOptional>; + }, z.core.$strip>>; + modelPreferences: z.ZodOptional; + }, z.core.$strip>>>; + costPriority: z.ZodOptional; + speedPriority: z.ZodOptional; + intelligencePriority: z.ZodOptional; + }, z.core.$strip>>; + systemPrompt: z.ZodOptional; + includeContext: z.ZodOptional>; + temperature: z.ZodOptional; + maxTokens: z.ZodNumber; + stopSequences: z.ZodOptional>; + metadata: z.ZodOptional>>; + tools: z.ZodOptional; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>>; + toolChoice: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"elicitation/create">; + params: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodOptional>; + message: z.ZodString; + requestedSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodRecord; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; + }, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>]>]>, z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>]>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + }, z.core.$strip>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodLiteral<"url">; + message: z.ZodString; + elicitationId: z.ZodString; + url: z.ZodString; + }, z.core.$strip>]>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"roots/list">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/get">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/result">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"tasks/list">; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/cancel">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type ServerResult = Infer; + +// @public (undocumented) +const ServerResultSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$strict>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + protocolVersion: z.ZodString; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + logging: z.ZodOptional>>; + completions: z.ZodOptional>>; + prompts: z.ZodOptional; + }, z.core.$strip>>; + resources: z.ZodOptional; + listChanged: z.ZodOptional; + }, z.core.$strip>>; + tools: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + serverInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + instructions: z.ZodOptional; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + completion: z.ZodObject<{ + values: z.ZodArray; + total: z.ZodOptional; + hasMore: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + description: z.ZodOptional; + messages: z.ZodArray; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + prompts: z.ZodArray; + arguments: z.ZodOptional; + required: z.ZodOptional; + }, z.core.$strip>>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + resources: z.ZodArray; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + resourceTemplates: z.ZodArray; + mimeType: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + contents: z.ZodArray; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + tools: z.ZodArray; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + tasks: z.ZodArray; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + task: z.ZodObject<{ + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$loose>]>; + +// @public (undocumented) +export type SetLevelRequest = Infer; + +// @public (undocumented) +export type SetLevelRequestParams = Infer; + +// @public +const SetLevelRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; +}, z.core.$strip>; + +// @public +const SetLevelRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"logging/setLevel">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type SingleSelectEnumSchema = Infer; + +// @public (undocumented) +const SingleSelectEnumSchemaSchema: z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; +}, z.core.$strip>]>; + +// @public +type SpecTypeInputs = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.input> : never }; + +// @public +export type SpecTypeName = StripSchemaSuffix; + +// @public +export type SpecTypes = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.output> : never }; + +// @public (undocumented) +export class SseError extends Error { + constructor(code: number | undefined, message: string | undefined, event: ErrorEvent_2); + // (undocumented) + readonly code: number | undefined; + // (undocumented) + readonly event: ErrorEvent_2; +} + +// @public (undocumented) +interface StandardJSONSchemaV1 { + // (undocumented) + readonly '~standard': StandardJSONSchemaV1.Props; +} + +// @public (undocumented) +namespace StandardJSONSchemaV1 { + // (undocumented) + interface Converter { + // (undocumented) + readonly input: (options: Options) => Record; + // (undocumented) + readonly output: (options: Options) => Record; + } + // (undocumented) + type InferInput = StandardTypedV1.InferInput; + // (undocumented) + type InferOutput = StandardTypedV1.InferOutput; + // (undocumented) + interface Options { + // (undocumented) + readonly libraryOptions?: Record | undefined; + // (undocumented) + readonly target: Target; + } + // (undocumented) + interface Props extends StandardTypedV1.Props { + // (undocumented) + readonly jsonSchema: Converter; + } + // (undocumented) + type Target = 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | (object & string); +} + +// @public (undocumented) +export interface StandardSchemaV1 { + // (undocumented) + readonly '~standard': StandardSchemaV1.Props; +} + +// @public (undocumented) +export namespace StandardSchemaV1 { + // (undocumented) + export interface FailureResult { + // (undocumented) + readonly issues: ReadonlyArray; + } + // (undocumented) + export type InferInput = StandardTypedV1.InferInput; + // (undocumented) + export type InferOutput = StandardTypedV1.InferOutput; + // (undocumented) + export interface Issue { + // (undocumented) + readonly message: string; + // (undocumented) + readonly path?: ReadonlyArray | undefined; + } + // (undocumented) + export interface Options { + // (undocumented) + readonly libraryOptions?: Record | undefined; + } + // (undocumented) + export interface PathSegment { + // (undocumented) + readonly key: PropertyKey; + } + // (undocumented) + export interface Props extends StandardTypedV1.Props { + // (undocumented) + readonly validate: (value: unknown, options?: Options | undefined) => Result | Promise>; + } + // (undocumented) + export type Result = SuccessResult | FailureResult; + // (undocumented) + export interface SuccessResult { + // (undocumented) + readonly issues?: undefined; + // (undocumented) + readonly value: Output; + } +} + +// @public +export interface StandardSchemaV1Sync extends StandardSchemaV1 { + // (undocumented) + readonly '~standard': StandardSchemaV1Sync.Props; +} + +// @public (undocumented) +export namespace StandardSchemaV1Sync { + // (undocumented) + export type InferInput = StandardTypedV1.InferInput; + // (undocumented) + export type InferOutput = StandardTypedV1.InferOutput; + // (undocumented) + export interface Props extends StandardSchemaV1.Props { + // (undocumented) + readonly validate: (value: unknown, options?: StandardSchemaV1.Options | undefined) => StandardSchemaV1.Result; + } +} + +// @public +export interface StandardSchemaWithJSON { + // (undocumented) + readonly '~standard': StandardSchemaV1.Props & StandardJSONSchemaV1.Props; +} + +// @public (undocumented) +export namespace StandardSchemaWithJSON { + // (undocumented) + export type InferInput = StandardTypedV1.InferInput; + // (undocumented) + export type InferOutput = StandardTypedV1.InferOutput; +} + +// @public +interface StandardTypedV1 { + // (undocumented) + readonly '~standard': StandardTypedV1.Props; +} + +// @public (undocumented) +namespace StandardTypedV1 { + // (undocumented) + type InferInput = NonNullable['input']; + // (undocumented) + type InferOutput = NonNullable['output']; + // (undocumented) + interface Props { + // (undocumented) + readonly types?: Types | undefined; + // (undocumented) + readonly vendor: string; + // (undocumented) + readonly version: 1; + } + // (undocumented) + interface Types { + // (undocumented) + readonly input: Input; + // (undocumented) + readonly output: Output; + } +} + +// @public +export interface StartSSEOptions { + onresumptiontoken?: (token: string) => void; + replayMessageId?: string | number; + resumptionToken?: string; +} + +// @public +export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { + constructor(options: StaticPrivateKeyJwtProviderOptions); + // (undocumented) + addClientAuthentication: AddClientAuthentication; + // (undocumented) + clientInformation(): OAuthClientInformation; + // (undocumented) + get clientMetadata(): OAuthClientMetadata; + // (undocumented) + codeVerifier(): string; + // (undocumented) + prepareTokenRequest(scope?: string): URLSearchParams; + // (undocumented) + redirectToAuthorization(): void; + // (undocumented) + get redirectUrl(): undefined; + // (undocumented) + saveClientInformation(info: OAuthClientInformation): void; + // (undocumented) + saveCodeVerifier(): void; + // (undocumented) + saveTokens(tokens: OAuthTokens): void; + // (undocumented) + tokens(): OAuthTokens | undefined; +} + +// @public +export interface StaticPrivateKeyJwtProviderOptions { + clientId: string; + clientName?: string; + jwtBearerAssertion: string; + scope?: string; +} + +// @public +export class StreamableHTTPClientTransport implements Transport { + constructor(url: URL, opts?: StreamableHTTPClientTransportOptions); + // (undocumented) + close(): Promise; + finishAuth(authorizationCode: string): Promise; + // (undocumented) + onclose?: () => void; + // (undocumented) + onerror?: (error: Error) => void; + // (undocumented) + onmessage?: (message: JSONRPCMessage) => void; + // (undocumented) + get protocolVersion(): string | undefined; + resumeStream(lastEventId: string, options?: { + onresumptiontoken?: (token: string) => void; + }): Promise; + // (undocumented) + send(message: JSONRPCMessage | JSONRPCMessage[], options?: { + resumptionToken?: string; + onresumptiontoken?: (token: string) => void; + }): Promise; + // (undocumented) + get sessionId(): string | undefined; + // (undocumented) + setProtocolVersion(version: string): void; + // (undocumented) + start(): Promise; + terminateSession(): Promise; +} + +// @public +export type StreamableHTTPClientTransportOptions = { + authProvider?: AuthProvider | OAuthClientProvider; + requestInit?: RequestInit; + fetch?: FetchLike; + reconnectionOptions?: StreamableHTTPReconnectionOptions; + reconnectionScheduler?: ReconnectionScheduler; + sessionId?: string; + protocolVersion?: string; +}; + +// @public +export interface StreamableHTTPReconnectionOptions { + initialReconnectionDelay: number; + maxReconnectionDelay: number; + maxRetries: number; + reconnectionDelayGrowFactor: number; +} + +// @public (undocumented) +export type StringSchema = Infer; + +// @public +const StringSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +type StripSchemaSuffix = K extends `${infer N}Schema` ? N : never; + +// @public (undocumented) +export type SubscribeRequest = Infer; + +// @public (undocumented) +export type SubscribeRequestParams = Infer; + +// @public (undocumented) +const SubscribeRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const SubscribeRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"resources/subscribe">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type Task = Infer; + +// @public (undocumented) +export type TaskAugmentedRequestParams = Infer; + +// @public +const TaskAugmentedRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type TaskCreationParams = Infer; + +// @public +const TaskCreationParamsSchema: z.ZodObject<{ + ttl: z.ZodOptional; + pollInterval: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type TaskMetadata = Infer; + +// @public (undocumented) +const TaskMetadataSchema: z.ZodObject<{ + ttl: z.ZodOptional; +}, z.core.$strip>; + +// @public +const TaskSchema: z.ZodObject<{ + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type TaskStatus = Infer; + +// @public (undocumented) +export type TaskStatusNotification = Infer; + +// @public (undocumented) +export type TaskStatusNotificationParams = Infer; + +// @public +const TaskStatusNotificationParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>; + +// @public +const TaskStatusNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/tasks/status">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public +const TaskStatusSchema: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; +}>; + +// @public (undocumented) +export type TextContent = Infer; + +// @public +const TextContentSchema: z.ZodObject<{ + type: z.ZodLiteral<"text">; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type TextResourceContents = Infer; + +// @public (undocumented) +const TextResourceContentsSchema: z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + text: z.ZodString; +}, z.core.$strip>; + +// @public (undocumented) +export type TitledMultiSelectEnumSchema = Infer; + +// @public +const TitledMultiSelectEnumSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type TitledSingleSelectEnumSchema = Infer; + +// @public +const TitledSingleSelectEnumSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type Tool = Infer; + +// @public (undocumented) +export type ToolAnnotations = Infer; + +// @public +const ToolAnnotationsSchema: z.ZodObject<{ + title: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ToolChoice = Infer; + +// @public +const ToolChoiceSchema: z.ZodObject<{ + mode: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type ToolExecution = Infer; + +// @public +const ToolExecutionSchema: z.ZodObject<{ + taskSupport: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type ToolListChangedNotification = Infer; + +// @public +const ToolListChangedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/tools/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type ToolResultContent = Infer; + +// @public +const ToolResultContentSchema: z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public +const ToolSchema: z.ZodObject<{ + description: z.ZodOptional; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ToolUseContent = Infer; + +// @public +const ToolUseContentSchema: z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public +export interface Transport { + close(): Promise; + onclose?: (() => void) | undefined; + onerror?: ((error: Error) => void) | undefined; + onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; + sessionId?: string | undefined; + setProtocolVersion?: ((version: string) => void) | undefined; + setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; + start(): Promise; +} + +// @public +export type TransportSendOptions = { + relatedRequestId?: RequestId | undefined; + resumptionToken?: string | undefined; + onresumptiontoken?: ((token: string) => void) | undefined; +}; + +// @public +interface UnauthorizedContext { + fetchFn: FetchLike; + response: Response; + serverUrl: URL; +} + +// @public (undocumented) +export class UnauthorizedError extends Error { + constructor(message?: string); +} + +// @public (undocumented) +export type UnsubscribeRequest = Infer; + +// @public (undocumented) +export type UnsubscribeRequestParams = Infer; + +// @public (undocumented) +const UnsubscribeRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const UnsubscribeRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"resources/unsubscribe">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public +export class UnsupportedProtocolVersionError extends ProtocolError { + constructor(data: UnsupportedProtocolVersionErrorData, message?: string); + get requested(): string; + get supported(): string[]; +} + +// @public +export interface UnsupportedProtocolVersionErrorData { + requested: string; + supported: string[]; +} + +// @public (undocumented) +export type UntitledMultiSelectEnumSchema = Infer; + +// @public +const UntitledMultiSelectEnumSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type UntitledSingleSelectEnumSchema = Infer; + +// @public +const UntitledSingleSelectEnumSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export class UriTemplate { + constructor(template: string); + // (undocumented) + expand(variables: Variables): string; + static isTemplate(str: string): boolean; + // (undocumented) + match(uri: string): Variables | null; + // (undocumented) + toString(): string; + // (undocumented) + get variableNames(): string[]; +} + +// @public +export class UrlElicitationRequiredError extends ProtocolError { + constructor(elicitations: ElicitRequestURLParams[], message?: string); + // (undocumented) + get elicitations(): ElicitRequestURLParams[]; +} + +// @public (undocumented) +export type Variables = Record; + +// @public +export const applyMiddlewares: (...middleware: Middleware[]) => Middleware; + +// @public (undocumented) +export function assertCompleteRequestPrompt(request: CompleteRequest): asserts request is CompleteRequestPrompt; + +// @public (undocumented) +export function assertCompleteRequestResourceTemplate(request: CompleteRequest): asserts request is CompleteRequestResourceTemplate; + +// @public +export function auth(provider: OAuthClientProvider, options: { + serverUrl: string | URL; + authorizationCode?: string; + scope?: string; + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; +}): Promise; + +// @public (undocumented) +const authSchemas: { + readonly OAuthClientInformationFullSchema: z.ZodObject<{ + redirect_uris: z.ZodArray; + token_endpoint_auth_method: z.ZodOptional; + grant_types: z.ZodOptional>; + response_types: z.ZodOptional>; + client_name: z.ZodOptional; + client_uri: z.ZodOptional; + logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + scope: z.ZodOptional; + contacts: z.ZodOptional>; + tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + policy_uri: z.ZodOptional; + jwks_uri: z.ZodOptional; + jwks: z.ZodOptional; + software_id: z.ZodOptional; + software_version: z.ZodOptional; + software_statement: z.ZodOptional; + client_id: z.ZodString; + client_secret: z.ZodOptional; + client_id_issued_at: z.ZodOptional; + client_secret_expires_at: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthClientInformationSchema: z.ZodObject<{ + client_id: z.ZodString; + client_secret: z.ZodOptional; + client_id_issued_at: z.ZodOptional; + client_secret_expires_at: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthClientMetadataSchema: z.ZodObject<{ + redirect_uris: z.ZodArray; + token_endpoint_auth_method: z.ZodOptional; + grant_types: z.ZodOptional>; + response_types: z.ZodOptional>; + client_name: z.ZodOptional; + client_uri: z.ZodOptional; + logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + scope: z.ZodOptional; + contacts: z.ZodOptional>; + tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + policy_uri: z.ZodOptional; + jwks_uri: z.ZodOptional; + jwks: z.ZodOptional; + software_id: z.ZodOptional; + software_version: z.ZodOptional; + software_statement: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthClientRegistrationErrorSchema: z.ZodObject<{ + error: z.ZodString; + error_description: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthErrorResponseSchema: z.ZodObject<{ + error: z.ZodString; + error_description: z.ZodOptional; + error_uri: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + revocation_endpoint: z.ZodOptional; + revocation_endpoint_auth_methods_supported: z.ZodOptional>; + revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + introspection_endpoint: z.ZodOptional; + introspection_endpoint_auth_methods_supported: z.ZodOptional>; + introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + code_challenge_methods_supported: z.ZodOptional>; + client_id_metadata_document_supported: z.ZodOptional; + }, z.core.$loose>; + readonly OAuthProtectedResourceMetadataSchema: z.ZodObject<{ + resource: z.ZodString; + authorization_servers: z.ZodOptional>; + jwks_uri: z.ZodOptional; + scopes_supported: z.ZodOptional>; + bearer_methods_supported: z.ZodOptional>; + resource_signing_alg_values_supported: z.ZodOptional>; + resource_name: z.ZodOptional; + resource_documentation: z.ZodOptional; + resource_policy_uri: z.ZodOptional; + resource_tos_uri: z.ZodOptional; + tls_client_certificate_bound_access_tokens: z.ZodOptional; + authorization_details_types_supported: z.ZodOptional>; + dpop_signing_alg_values_supported: z.ZodOptional>; + dpop_bound_access_tokens_required: z.ZodOptional; + }, z.core.$loose>; + readonly OAuthTokenRevocationRequestSchema: z.ZodObject<{ + token: z.ZodString; + token_type_hint: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthTokensSchema: z.ZodObject<{ + access_token: z.ZodString; + id_token: z.ZodOptional; + token_type: z.ZodString; + expires_in: z.ZodOptional>; + scope: z.ZodOptional; + refresh_token: z.ZodOptional; + }, z.core.$strip>; + readonly OpenIdProviderDiscoveryMetadataSchema: z.ZodObject<{ + code_challenge_methods_supported: z.ZodOptional>; + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + userinfo_endpoint: z.ZodOptional; + jwks_uri: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + acr_values_supported: z.ZodOptional>; + subject_types_supported: z.ZodArray; + id_token_signing_alg_values_supported: z.ZodArray; + id_token_encryption_alg_values_supported: z.ZodOptional>; + id_token_encryption_enc_values_supported: z.ZodOptional>; + userinfo_signing_alg_values_supported: z.ZodOptional>; + userinfo_encryption_alg_values_supported: z.ZodOptional>; + userinfo_encryption_enc_values_supported: z.ZodOptional>; + request_object_signing_alg_values_supported: z.ZodOptional>; + request_object_encryption_alg_values_supported: z.ZodOptional>; + request_object_encryption_enc_values_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + display_values_supported: z.ZodOptional>; + claim_types_supported: z.ZodOptional>; + claims_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + claims_locales_supported: z.ZodOptional>; + ui_locales_supported: z.ZodOptional>; + claims_parameter_supported: z.ZodOptional; + request_parameter_supported: z.ZodOptional; + request_uri_parameter_supported: z.ZodOptional; + require_request_uri_registration: z.ZodOptional; + op_policy_uri: z.ZodOptional; + op_tos_uri: z.ZodOptional; + client_id_metadata_document_supported: z.ZodOptional; + }, z.core.$strip>; + readonly OpenIdProviderMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + userinfo_endpoint: z.ZodOptional; + jwks_uri: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + acr_values_supported: z.ZodOptional>; + subject_types_supported: z.ZodArray; + id_token_signing_alg_values_supported: z.ZodArray; + id_token_encryption_alg_values_supported: z.ZodOptional>; + id_token_encryption_enc_values_supported: z.ZodOptional>; + userinfo_signing_alg_values_supported: z.ZodOptional>; + userinfo_encryption_alg_values_supported: z.ZodOptional>; + userinfo_encryption_enc_values_supported: z.ZodOptional>; + request_object_signing_alg_values_supported: z.ZodOptional>; + request_object_encryption_alg_values_supported: z.ZodOptional>; + request_object_encryption_enc_values_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + display_values_supported: z.ZodOptional>; + claim_types_supported: z.ZodOptional>; + claims_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + claims_locales_supported: z.ZodOptional>; + ui_locales_supported: z.ZodOptional>; + claims_parameter_supported: z.ZodOptional; + request_parameter_supported: z.ZodOptional; + request_uri_parameter_supported: z.ZodOptional; + require_request_uri_registration: z.ZodOptional; + op_policy_uri: z.ZodOptional; + op_tos_uri: z.ZodOptional; + client_id_metadata_document_supported: z.ZodOptional; + }, z.core.$loose>; +}; + +// @public +export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { + url: URL; + type: 'oauth' | 'oidc'; +}[]; + +// @public +export function checkResourceAllowed(input: { + requestedResource: URL | string; + configuredResource: URL | string; +}): boolean; + +// @public +export function createFetchWithInit(baseFetch?: FetchLike, baseInit?: RequestInit): FetchLike; + +// @public +export const createMiddleware: (handler: (next: FetchLike, input: string | URL, init?: RequestInit) => Promise) => Middleware; + +// @public +export function createPrivateKeyJwtAuth(options: { + issuer: string; + subject: string; + privateKey: string | Uint8Array | Record; + alg: string; + audience?: string | URL; + lifetimeSeconds?: number; + claims?: Record; +}): AddClientAuthentication; + +// @public (undocumented) +export function deserializeMessage(line: string): JSONRPCMessage; + +// @public +export function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise; + +// @public +export function discoverAuthorizationServerMetadata(authorizationServerUrl: string | URL, input?: { + fetchFn?: FetchLike; + protocolVersion?: string; +}): Promise; + +// @public @deprecated +export function discoverOAuthMetadata(issuer: string | URL, input?: { + authorizationServerUrl?: string | URL; + protocolVersion?: string; +}, fetchFn?: FetchLike): Promise; + +// @public +export function discoverOAuthProtectedResourceMetadata(serverUrl: string | URL, opts?: { + protocolVersion?: string; + resourceMetadataUrl?: string | URL; +}, fetchFn?: FetchLike): Promise; + +// @public +export function discoverOAuthServerInfo(serverUrl: string | URL, opts?: { + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; +}): Promise; + +// @public +export function exchangeAuthorization(authorizationServerUrl: string | URL, input: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformationMixed; + authorizationCode: string; + codeVerifier: string; + redirectUri: string | URL; + resource?: URL; + addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + fetchFn?: FetchLike; +}): Promise; + +// @public +export function exchangeJwtAuthGrant(options: { + tokenEndpoint: string | URL; + jwtAuthGrant: string; + clientId: string; + clientSecret?: string; + authMethod?: ClientAuthMethod; + fetchFn?: FetchLike; +}): Promise<{ + access_token: string; + token_type: string; + expires_in?: number; + scope?: string; +}>; + +// @public @deprecated +export function extractResourceMetadataUrl(res: Response): URL | undefined; + +// @public +export function extractWWWAuthenticateParams(res: Response): { + resourceMetadataUrl?: URL; + scope?: string; + error?: string; +}; + +// @public +export function fetchToken(provider: OAuthClientProvider, authorizationServerUrl: string | URL, input?: { + metadata?: AuthorizationServerMetadata; + resource?: URL; + authorizationCode?: string; + scope?: string; + fetchFn?: FetchLike; +}): Promise; + +// @public (undocumented) +export function fromJsonSchema(schema: JsonSchemaType, validator?: jsonSchemaValidator): StandardSchemaWithJSON; + +// @public +export function getDisplayName(metadata: BaseMetadata | (BaseMetadata & { + annotations?: { + title?: string; + }; +})): string; + +// @public +export function getSupportedElicitationModes(capabilities: ClientCapabilities['elicitation']): { + supportsFormMode: boolean; + supportsUrlMode: boolean; +}; + +// @public +export const isCallToolResult: (value: unknown) => value is CallToolResult; + +// @public +export function isHttpsUrl(value?: string): boolean; + +// @public (undocumented) +export const isInitializeRequest: (value: unknown) => value is InitializeRequest; + +// @public (undocumented) +export const isInitializedNotification: (value: unknown) => value is InitializedNotification; + +// @public +export const isJSONRPCErrorResponse: (value: unknown) => value is JSONRPCErrorResponse; + +// @public (undocumented) +export const isJSONRPCNotification: (value: unknown) => value is JSONRPCNotification; + +// @public (undocumented) +export const isJSONRPCRequest: (value: unknown) => value is JSONRPCRequest; + +// @public +export const isJSONRPCResponse: (value: unknown) => value is JSONRPCResponse; + +// @public +export const isJSONRPCResultResponse: (value: unknown) => value is JSONRPCResultResponse; + +// @public +export const isSpecType: GuardRecord; + +// @public +export const isTaskAugmentedRequestParams: (value: unknown) => value is TaskAugmentedRequestParams; + +// @public +export interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +export function parseErrorResponse(input: Response | string): Promise; + +// @public +export function parseJSONRPCMessage(value: unknown): JSONRPCMessage; + +// @public +export function prepareAuthorizationCodeRequest(authorizationCode: string, codeVerifier: string, redirectUri: string | URL): URLSearchParams; + +// @public +export function refreshAuthorization(authorizationServerUrl: string | URL, input: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformationMixed; + refreshToken: string; + resource?: URL; + addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + fetchFn?: FetchLike; +}): Promise; + +// @public +export function registerClient(authorizationServerUrl: string | URL, input: { + metadata?: AuthorizationServerMetadata; + clientMetadata: OAuthClientMetadata; + scope?: string; + fetchFn?: FetchLike; +}): Promise; + +// @public +export function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise; + +// @public +export function resourceUrlFromServerUrl(url: URL | string): URL; + +// @public (undocumented) +namespace schemas_d_exports { + export { AnnotationsSchema, AudioContentSchema, BaseMetadataSchema, BaseRequestParamsSchema, BlobResourceContentsSchema, BooleanSchemaSchema, CallToolRequestParamsSchema, CallToolRequestSchema, CallToolResultSchema, CancelTaskRequestSchema, CancelTaskResultSchema, CancelledNotificationParamsSchema, CancelledNotificationSchema, ClientCapabilitiesSchema, ClientNotificationSchema, ClientRequestSchema, ClientResultSchema, ClientTasksCapabilitySchema, CompatibilityCallToolResultSchema, CompleteRequestParamsSchema, CompleteRequestSchema, CompleteResultSchema, ContentBlockSchema, CreateMessageRequestParamsSchema, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, CreateTaskResultSchema, CursorSchema, DiscoverRequestSchema, DiscoverResultSchema, ElicitRequestFormParamsSchema, ElicitRequestParamsSchema, ElicitRequestSchema, ElicitRequestURLParamsSchema, ElicitResultSchema, ElicitationCompleteNotificationParamsSchema, ElicitationCompleteNotificationSchema, EmbeddedResourceSchema, EmptyResultSchema, EnumSchemaSchema, GetPromptRequestParamsSchema, GetPromptRequestSchema, GetPromptResultSchema, GetTaskPayloadRequestSchema, GetTaskPayloadResultSchema, GetTaskRequestSchema, GetTaskResultSchema, IconSchema, IconsSchema, ImageContentSchema, ImplementationSchema, InitializeRequestParamsSchema, InitializeRequestSchema, InitializeResultSchema, InitializedNotificationSchema, JSONArraySchema, JSONObjectSchema, JSONRPCErrorResponseSchema, JSONRPCMessageSchema, JSONRPCNotificationSchema, JSONRPCRequestSchema, JSONRPCResponseSchema, JSONRPCResultResponseSchema, JSONValueSchema, LegacyTitledEnumSchemaSchema, ListChangedOptionsBaseSchema, ListPromptsRequestSchema, ListPromptsResultSchema, ListResourceTemplatesRequestSchema, ListResourceTemplatesResultSchema, ListResourcesRequestSchema, ListResourcesResultSchema, ListRootsRequestSchema, ListRootsResultSchema, ListTasksRequestSchema, ListTasksResultSchema, ListToolsRequestSchema, ListToolsResultSchema, LoggingLevelSchema, LoggingMessageNotificationParamsSchema, LoggingMessageNotificationSchema, ModelHintSchema, ModelPreferencesSchema, MultiSelectEnumSchemaSchema, NotificationSchema, NotificationsParamsSchema, NumberSchemaSchema, PaginatedRequestParamsSchema, PaginatedRequestSchema, PaginatedResultSchema, PingRequestSchema, PrimitiveSchemaDefinitionSchema, ProgressNotificationParamsSchema, ProgressNotificationSchema, ProgressSchema, ProgressTokenSchema, PromptArgumentSchema, PromptListChangedNotificationSchema, PromptMessageSchema, PromptReferenceSchema, PromptSchema, ReadResourceRequestParamsSchema, ReadResourceRequestSchema, ReadResourceResultSchema, RelatedTaskMetadataSchema, RequestIdSchema, RequestMetaEnvelopeSchema, RequestMetaSchema, RequestSchema, ResourceContentsSchema, ResourceLinkSchema, ResourceListChangedNotificationSchema, ResourceRequestParamsSchema, ResourceSchema, ResourceTemplateReferenceSchema, ResourceTemplateSchema, ResourceUpdatedNotificationParamsSchema, ResourceUpdatedNotificationSchema, ResultSchema, RoleSchema, RootSchema, RootsListChangedNotificationSchema, SamplingContentSchema, SamplingMessageContentBlockSchema, SamplingMessageSchema, ServerCapabilitiesSchema, ServerNotificationSchema, ServerRequestSchema, ServerResultSchema, ServerTasksCapabilitySchema, SetLevelRequestParamsSchema, SetLevelRequestSchema, SingleSelectEnumSchemaSchema, StringSchemaSchema, SubscribeRequestParamsSchema, SubscribeRequestSchema, TaskAugmentedRequestParamsSchema, TaskCreationParamsSchema, TaskMetadataSchema, TaskSchema, TaskStatusNotificationParamsSchema, TaskStatusNotificationSchema, TaskStatusSchema, TextContentSchema, TextResourceContentsSchema, TitledMultiSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema, ToolAnnotationsSchema, ToolChoiceSchema, ToolExecutionSchema, ToolListChangedNotificationSchema, ToolResultContentSchema, ToolSchema, ToolUseContentSchema, UnsubscribeRequestParamsSchema, UnsubscribeRequestSchema, UntitledMultiSelectEnumSchemaSchema, UntitledSingleSelectEnumSchemaSchema, getNotificationSchema, getRequestSchema, getResultSchema }; +} + +// @public +export function selectClientAuthMethod(clientInformation: OAuthClientInformationMixed, supportedMethods: string[]): ClientAuthMethod; + +// @public (undocumented) +export function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise; + +// @public (undocumented) +export function serializeMessage(message: JSONRPCMessage): string; + +// @public +export const specTypeSchemas: SchemaRecord; + +// @public +export function startAuthorization(authorizationServerUrl: string | URL, input: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformationMixed; + redirectUrl: string | URL; + scope?: string; + state?: string; + resource?: URL; +}): Promise<{ + authorizationUrl: URL; + codeVerifier: string; +}>; + +// @public +export function validateClientMetadataUrl(url: string | undefined): void; + +// @public +export const withLogging: (options?: LoggingOptions) => Middleware; + +// @public +export const withOAuth: (provider: OAuthClientProvider, baseUrl?: string | URL) => Middleware; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/client/etc/client.shims-browser.api.md b/packages/client/etc/client.shims-browser.api.md new file mode 100644 index 0000000000..201e6a97d4 --- /dev/null +++ b/packages/client/etc/client.shims-browser.api.md @@ -0,0 +1,47 @@ +## API Report File for "@modelcontextprotocol/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JSONSchema } from 'json-schema-typed'; + +// @public +export const CORS_IS_POSSIBLE = true; + +// @public +type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; + +// @public +export class DefaultJsonSchemaValidator implements jsonSchemaValidator { + constructor(options?: { + shortcircuit?: boolean; + draft?: CfWorkerSchemaDraft; + }); + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +type JsonSchemaType = JSONSchema.Interface; + +// @public +type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public +interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/client/etc/client.shims-workerd.api.md b/packages/client/etc/client.shims-workerd.api.md new file mode 100644 index 0000000000..f57cf3690f --- /dev/null +++ b/packages/client/etc/client.shims-workerd.api.md @@ -0,0 +1,47 @@ +## API Report File for "@modelcontextprotocol/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JSONSchema } from 'json-schema-typed'; + +// @public +export const CORS_IS_POSSIBLE = false; + +// @public +type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; + +// @public +export class DefaultJsonSchemaValidator implements jsonSchemaValidator { + constructor(options?: { + shortcircuit?: boolean; + draft?: CfWorkerSchemaDraft; + }); + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +type JsonSchemaType = JSONSchema.Interface; + +// @public +type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public +interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/client/etc/client.shims.api.md b/packages/client/etc/client.shims.api.md new file mode 100644 index 0000000000..512ebf6bd8 --- /dev/null +++ b/packages/client/etc/client.shims.api.md @@ -0,0 +1,60 @@ +## API Report File for "@modelcontextprotocol/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JSONSchema } from 'json-schema-typed'; + +// @public +interface AjvLike { + // (undocumented) + compile: (schema: unknown) => AjvValidateFunction; + // (undocumented) + errorsText: (errors?: any) => string; + // (undocumented) + getSchema: (keyRef: string) => AjvValidateFunction | undefined; +} + +// @public (undocumented) +interface AjvValidateFunction { + // (undocumented) + (input: unknown): boolean; + // (undocumented) + errors?: any; +} + +// @public +export const CORS_IS_POSSIBLE = false; + +// @public +export class DefaultJsonSchemaValidator implements jsonSchemaValidator { + constructor(ajv?: AjvLike); + // (undocumented) + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +type JsonSchemaType = JSONSchema.Interface; + +// @public +type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public +interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/client/etc/client.stdio.api.md b/packages/client/etc/client.stdio.api.md new file mode 100644 index 0000000000..8b5367b579 --- /dev/null +++ b/packages/client/etc/client.stdio.api.md @@ -0,0 +1,148 @@ +## API Report File for "@modelcontextprotocol/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { IOType } from 'node:child_process'; +import { Stream } from 'node:stream'; +import * as z from 'zod/v4'; + +// @public +interface AuthInfo { + clientId: string; + expiresAt?: number; + extra?: Record; + resource?: URL; + scopes: string[]; + token: string; +} + +// @public +export const DEFAULT_INHERITED_ENV_VARS: string[]; + +// @public (undocumented) +type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; + +// @public (undocumented) +type Infer = Flatten>; + +// @public (undocumented) +type JSONRPCMessage = Infer; + +// @public (undocumented) +const JSONRPCMessageSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; +}, z.core.$strict>, z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>]>; + +// @public +interface MessageExtraInfo { + authInfo?: AuthInfo; + closeSSEStream?: () => void; + closeStandaloneSSEStream?: () => void; + request?: globalThis.Request; +} + +// @public (undocumented) +type Primitive = string | number | boolean | bigint | null | undefined; + +// @public (undocumented) +type RequestId = Infer; + +// @public +const RequestIdSchema: z.ZodUnion; + +// @public +export class StdioClientTransport implements Transport { + constructor(server: StdioServerParameters); + // (undocumented) + close(): Promise; + // (undocumented) + onclose?: () => void; + // (undocumented) + onerror?: (error: Error) => void; + // (undocumented) + onmessage?: (message: JSONRPCMessage) => void; + get pid(): number | null; + // (undocumented) + send(message: JSONRPCMessage): Promise; + start(): Promise; + get stderr(): Stream | null; +} + +// @public (undocumented) +export type StdioServerParameters = { + command: string; + args?: string[]; + env?: Record; + stderr?: IOType | Stream | number; + cwd?: string; + maxBufferSize?: number; +}; + +// @public +interface Transport { + close(): Promise; + onclose?: (() => void) | undefined; + onerror?: ((error: Error) => void) | undefined; + onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; + sessionId?: string | undefined; + setProtocolVersion?: ((version: string) => void) | undefined; + setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; + start(): Promise; +} + +// @public +type TransportSendOptions = { + relatedRequestId?: RequestId | undefined; + resumptionToken?: string | undefined; + onresumptiontoken?: ((token: string) => void) | undefined; +}; + +// @public +export function getDefaultEnvironment(): Record; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/client/etc/client.validators-ajv.api.md b/packages/client/etc/client.validators-ajv.api.md new file mode 100644 index 0000000000..c7759ab39c --- /dev/null +++ b/packages/client/etc/client.validators-ajv.api.md @@ -0,0 +1,1572 @@ +## API Report File for "@modelcontextprotocol/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JSONSchema } from 'json-schema-typed'; + +// @public (undocumented) +type AddedFormat = true | RegExp | FormatValidator | FormatDefinition | FormatDefinition | AsyncFormatDefinition | AsyncFormatDefinition; + +// @public (undocumented) +type AddedKeywordDefinition = KeywordDefinition & { + type: JSONType[]; + schemaType: JSONType[]; +}; + +// @public (undocumented) +export class Ajv extends Ajv$2 { + // (undocumented) + _addDefaultMetaSchema(): void; + // (undocumented) + _addVocabularies(): void; + // (undocumented) + defaultMeta(): string | AnySchemaObject | undefined; +} + +// @public (undocumented) +class Ajv$2 { + // (undocumented) + $dataMetaSchema(metaSchema: AnySchemaObject, keywordsJsonPointers: string[]): AnySchemaObject; + constructor(opts?: Options); + // (undocumented) + _addDefaultMetaSchema(): void; + // (undocumented) + addFormat(name: string, format: Format): Ajv$2; + // (undocumented) + addKeyword(kwdOrDef: string | KeywordDefinition, def?: KeywordDefinition): Ajv$2; + // (undocumented) + addMetaSchema(schema: AnySchemaObject, key?: string, + // schema key + _validateSchema?: boolean | "log"): Ajv$2; + // (undocumented) + addSchema(schema: AnySchema | AnySchema[], + // If array is passed, `key` will be ignored + key?: string, + // Optional schema key. Can be passed to `validate` method instead of schema object or id/ref. One schema per instance can have empty `id` and `key`. + _meta?: boolean, + // true if schema is a meta-schema. Used internally, addMetaSchema should be used instead. + _validateSchema?: boolean | "log"): Ajv$2; + // (undocumented) + _addSchema(schema: AnySchema, meta?: boolean, baseId?: string, validateSchema?: boolean | "log", addSchema?: boolean): SchemaEnv; + // (undocumented) + _addVocabularies(): void; + // (undocumented) + addVocabulary(definitions: Vocabulary): Ajv$2; + // (undocumented) + readonly _compilations: Set; + // (undocumented) + compile(schema: Schema | JSONSchemaType, _meta?: boolean): ValidateFunction; + // (undocumented) + compile(schema: JTDSchemaType, _meta?: boolean): ValidateFunction; + // (undocumented) + compile(schema: T, _meta?: boolean): ValidateFunction>; + // (undocumented) + compile(schema: AsyncSchema, _meta?: boolean): AsyncValidateFunction; + // (undocumented) + compile(schema: AnySchema, _meta?: boolean): AnyValidateFunction; + // (undocumented) + compileAsync(schema: SchemaObject | JSONSchemaType, _meta?: boolean): Promise>; + // (undocumented) + compileAsync(schema: JTDSchemaType, _meta?: boolean): Promise>; + // (undocumented) + compileAsync(schema: AsyncSchema, meta?: boolean): Promise>; + // (undocumented) + compileAsync(schema: AnySchemaObject, meta?: boolean): Promise>; + // (undocumented) + defaultMeta(): string | AnySchemaObject | undefined; + // (undocumented) + errors?: ErrorObject[] | null; + // (undocumented) + errorsText(errors?: ErrorObject[] | null | undefined, + // optional array of validation errors + input?: ErrorsTextOptions): string; + // (undocumented) + readonly formats: { [Name in string]?: AddedFormat }; + // (undocumented) + getKeyword(keyword: string): AddedKeywordDefinition | boolean; + // (undocumented) + getSchema(keyRef: string): AnyValidateFunction | undefined; + // (undocumented) + logger: Logger; + // (undocumented) + static MissingRefError: typeof MissingRefError; + // (undocumented) + opts: InstanceOptions; + // (undocumented) + readonly refs: { [Ref in string]?: SchemaEnv | string }; + // (undocumented) + removeKeyword(keyword: string): Ajv$2; + // (undocumented) + removeSchema(schemaKeyRef?: AnySchema | string | RegExp): Ajv$2; + // (undocumented) + readonly RULES: ValidationRules; + // (undocumented) + readonly schemas: { [Key in string]?: SchemaEnv }; + // (undocumented) + readonly scope: ValueScope; + // (undocumented) + validate(schema: Schema | string, data: unknown): boolean; + // (undocumented) + validate(schemaKeyRef: AnySchema | string, data: unknown): boolean | Promise; + // (undocumented) + validate(schema: Schema | JSONSchemaType | string, data: unknown): data is T; + // (undocumented) + validate(schema: JTDSchemaType, data: unknown): data is T; + // (undocumented) + validate(schema: T, data: unknown): data is JTDDataType; + // (undocumented) + validate(schema: AsyncSchema, data: unknown | T): Promise; + // (undocumented) + validate(schemaKeyRef: AnySchema | string, data: unknown): data is T | Promise; + // (undocumented) + validateSchema(schema: AnySchema, throwOrLogError?: boolean): boolean | Promise; + // (undocumented) + static ValidationError: typeof ValidationError; +} + +// @public +export class AjvJsonSchemaValidator implements jsonSchemaValidator { + constructor(ajv?: AjvLike); + // (undocumented) + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +interface AjvLike { + // (undocumented) + compile: (schema: unknown) => AjvValidateFunction; + // (undocumented) + errorsText: (errors?: any) => string; + // (undocumented) + getSchema: (keyRef: string) => AjvValidateFunction | undefined; +} + +// @public (undocumented) +interface AjvValidateFunction { + // (undocumented) + (input: unknown): boolean; + // (undocumented) + errors?: any; +} + +// @public (undocumented) +type AnySchema = Schema | AsyncSchema; + +// @public (undocumented) +type AnySchemaObject = SchemaObject | AsyncSchema; + +// @public (undocumented) +type AnyValidateFunction = ValidateFunction | AsyncValidateFunction; + +// @public (undocumented) +interface AsyncFormatDefinition { + // (undocumented) + async: true; + // (undocumented) + compare?: FormatCompare; + // (undocumented) + type?: T extends string ? "string" | undefined : "number"; + // (undocumented) + validate: AsyncFormatValidator; +} + +// @public (undocumented) +type AsyncFormatValidator = (data: T) => Promise; + +// @public (undocumented) +interface AsyncSchema extends _SchemaObject { + // (undocumented) + $async: true; +} + +// @public (undocumented) +interface AsyncValidateFunction extends ValidateFunction { + // (undocumented) + $async: true; + // (undocumented) + (...args: Parameters>): Promise; +} + +// @public (undocumented) +type Block = Code | (() => void); + +// @public (undocumented) +type Code = _Code | Name; + +// @public (undocumented) +class CodeGen { + constructor(extScope: ValueScope, opts?: CodeGenOptions); + // (undocumented) + add(lhs: Code, rhs: SafeExpr): CodeGen; + // (undocumented) + assign(lhs: Code, rhs: SafeExpr, sideEffects?: boolean): CodeGen; + // (undocumented) + block(body?: Block, nodeCount?: number): CodeGen; + // (undocumented) + break(label?: Code): CodeGen; + // (undocumented) + code(c: Block | SafeExpr): CodeGen; + // (undocumented) + const(nameOrPrefix: Name | string, rhs: SafeExpr, _constant?: boolean): Name; + // (undocumented) + else(): CodeGen; + // (undocumented) + elseIf(condition: Code | boolean): CodeGen; + // (undocumented) + endBlock(nodeCount?: number): CodeGen; + // (undocumented) + endFor(): CodeGen; + // (undocumented) + endFunc(): CodeGen; + // (undocumented) + endIf(): CodeGen; + // (undocumented) + readonly _extScope: ValueScope; + // (undocumented) + for(iteration: Code, forBody?: Block): CodeGen; + // (undocumented) + forIn(nameOrPrefix: Name | string, obj: Code, forBody: (item: Name) => void, varKind?: Code): CodeGen; + // (undocumented) + forOf(nameOrPrefix: Name | string, iterable: Code, forBody: (item: Name) => void, varKind?: Code): CodeGen; + // (undocumented) + forRange(nameOrPrefix: Name | string, from: SafeExpr, to: SafeExpr, forBody: (index: Name) => void, varKind?: Code): CodeGen; + // (undocumented) + func(name: Name, args?: Code, async?: boolean, funcBody?: Block): CodeGen; + // (undocumented) + getScopeValue(prefix: string, keyOrRef: unknown): ValueScopeName | undefined; + // (undocumented) + if(condition: Code | boolean, thenBody?: Block, elseBody?: Block): CodeGen; + // (undocumented) + label(label: Name): CodeGen; + // (undocumented) + let(nameOrPrefix: Name | string, rhs?: SafeExpr, _constant?: boolean): Name; + // (undocumented) + name(prefix: string): Name; + // (undocumented) + object(...keyValues: [Name | string, SafeExpr | string][]): _Code; + // (undocumented) + optimize(n?: number): void; + // (undocumented) + return(value: Block | SafeExpr): CodeGen; + // (undocumented) + readonly _scope: Scope; + // (undocumented) + scopeCode(): Code; + // (undocumented) + scopeName(prefix: string): ValueScopeName; + // (undocumented) + scopeRefs(scopeName: Name): Code; + // (undocumented) + scopeValue(prefixOrName: ValueScopeName | string, value: NameValue): Name; + // (undocumented) + throw(error: Code): CodeGen; + // (undocumented) + toString(): string; + // (undocumented) + try(tryBody: Block, catchCode?: (e: Name) => void, finallyCode?: Block): CodeGen; + // (undocumented) + readonly _values: ScopeValueSets; + // (undocumented) + var(nameOrPrefix: Name | string, rhs?: SafeExpr, _constant?: boolean): Name; +} + +// @public (undocumented) +interface CodeGenOptions { + // (undocumented) + es5?: boolean; + // (undocumented) + lines?: boolean; + // (undocumented) + ownProperties?: boolean; +} + +// @public (undocumented) +type CodeItem = Name | string | number | boolean | null; + +// @public (undocumented) +interface CodeKeywordDefinition extends _KeywordDef { + // (undocumented) + code: (cxt: KeywordCxt, ruleType?: string) => void; + // (undocumented) + trackErrors?: boolean; +} + +// @public (undocumented) +interface CodeOptions { + // (undocumented) + es5?: boolean; + // (undocumented) + esm?: boolean; + // (undocumented) + formats?: Code; + // (undocumented) + lines?: boolean; + // (undocumented) + optimize?: boolean | number; + // (undocumented) + process?: (code: string, schema?: SchemaEnv) => string; + // (undocumented) + regExp?: RegExpEngine; + // (undocumented) + source?: boolean; +} + +// @public (undocumented) +type CompileKeywordFunc = (schema: any, parentSchema: AnySchemaObject, it: SchemaObjCxt) => DataValidateFunction; + +// @public (undocumented) +interface CurrentOptions { + // (undocumented) + $comment?: true | ((comment: string, schemaPath?: string, rootSchema?: AnySchemaObject) => unknown); + // (undocumented) + $data?: boolean; + // (undocumented) + addUsedSchema?: boolean; + // (undocumented) + allErrors?: boolean; + // (undocumented) + allowDate?: boolean; + // (undocumented) + allowMatchingProperties?: boolean; + // (undocumented) + allowUnionTypes?: boolean; + // (undocumented) + code?: CodeOptions; + // (undocumented) + coerceTypes?: boolean | "array"; + // (undocumented) + defaultMeta?: string | AnySchemaObject; + // (undocumented) + discriminator?: boolean; + // (undocumented) + dynamicRef?: boolean; + // (undocumented) + formats?: { [Name in string]?: Format }; + // (undocumented) + inlineRefs?: boolean | number; + // (undocumented) + int32range?: boolean; + // (undocumented) + jtd?: boolean; + // (undocumented) + keywords?: Vocabulary; + // (undocumented) + loadSchema?: (uri: string) => Promise; + // (undocumented) + logger?: Logger | false; + // (undocumented) + loopEnum?: number; + // (undocumented) + loopRequired?: number; + // (undocumented) + messages?: boolean; + // (undocumented) + meta?: SchemaObject | boolean; + // (undocumented) + multipleOfPrecision?: number; + // (undocumented) + next?: boolean; + // (undocumented) + ownProperties?: boolean; + // (undocumented) + parseDate?: boolean; + // (undocumented) + passContext?: boolean; + // (undocumented) + removeAdditional?: boolean | "all" | "failing"; + // (undocumented) + schemaId?: "id" | "$id"; + // (undocumented) + schemas?: AnySchema[] | { [Key in string]?: AnySchema }; + // (undocumented) + specialNumbers?: "fast" | "null"; + // (undocumented) + strict?: boolean | "log"; + // (undocumented) + strictNumbers?: boolean | "log"; + // (undocumented) + strictRequired?: boolean | "log"; + // (undocumented) + strictSchema?: boolean | "log"; + // (undocumented) + strictTuples?: boolean | "log"; + // (undocumented) + strictTypes?: boolean | "log"; + // (undocumented) + timestamp?: "string" | "date"; + // (undocumented) + unevaluated?: boolean; + // (undocumented) + unicodeRegExp?: boolean; + // (undocumented) + uriResolver?: UriResolver; + // (undocumented) + useDefaults?: boolean | "empty"; + // (undocumented) + validateFormats?: boolean; + // (undocumented) + validateSchema?: boolean | "log"; + // (undocumented) + verbose?: boolean; +} + +// @public (undocumented) +interface DataValidateFunction { + // (undocumented) + (...args: Parameters): boolean | Promise; + // (undocumented) + errors?: Partial[]; +} + +// @public (undocumented) +interface DataValidationCxt { + // (undocumented) + dynamicAnchors: { [Ref in string]?: ValidateFunction }; + // (undocumented) + instancePath: string; + // (undocumented) + parentData: { [K in T]: any }; + // (undocumented) + parentDataProperty: T; + // (undocumented) + rootData: Record | any[]; +} + +// @public (undocumented) +interface DeprecatedOptions { + // @deprecated (undocumented) + ignoreKeywordsWithRef?: boolean; + // @deprecated (undocumented) + jsPropertySyntax?: boolean; + // @deprecated (undocumented) + unicode?: boolean; +} + +// @public +type EnumString = [T] extends [never] ? null : T extends string ? string extends T ? null : T : null; + +// @public (undocumented) +interface ErrorObject, S = unknown> { + // (undocumented) + data?: unknown; + // (undocumented) + instancePath: string; + // (undocumented) + keyword: K; + // (undocumented) + message?: string; + // (undocumented) + params: P; + // (undocumented) + parentSchema?: AnySchemaObject; + // (undocumented) + propertyName?: string; + // (undocumented) + schema?: S; + // (undocumented) + schemaPath: string; +} + +// @public (undocumented) +interface ErrorPaths { + // (undocumented) + instancePath?: Code; + // (undocumented) + parentSchema?: boolean; + // (undocumented) + schemaPath?: string; +} + +// @public (undocumented) +interface ErrorsTextOptions { + // (undocumented) + dataVar?: string; + // (undocumented) + separator?: string; +} + +// @public (undocumented) +interface Evaluated { + // (undocumented) + dynamicItems: boolean; + // (undocumented) + dynamicProps: boolean; + // (undocumented) + items?: EvaluatedItems; + // (undocumented) + props?: EvaluatedProperties; +} + +// @public (undocumented) +type EvaluatedItems = number | true; + +// @public (undocumented) +type EvaluatedProperties = { [K in string]?: true } | true; + +// @public (undocumented) +type Format = AddedFormat | string; + +// @public (undocumented) +type FormatCompare = (data1: T, data2: T) => number | undefined; + +// @public (undocumented) +interface FormatDefinition { + // (undocumented) + async?: false | undefined; + // (undocumented) + compare?: FormatCompare; + // (undocumented) + type?: T extends string ? "string" | undefined : "number"; + // (undocumented) + validate: FormatValidator | (T extends string ? string | RegExp : never); +} + +// @public (undocumented) +type FormatMode = "fast" | "full"; + +// @public (undocumented) +type FormatName = "date" | "time" | "date-time" | "iso-time" | "iso-date-time" | "duration" | "uri" | "uri-reference" | "uri-template" | "url" | "email" | "hostname" | "ipv4" | "ipv6" | "regex" | "uuid" | "json-pointer" | "json-pointer-uri-fragment" | "relative-json-pointer" | "byte" | "int32" | "int64" | "float" | "double" | "password" | "binary"; + +// @public (undocumented) +interface FormatOptions { + // (undocumented) + formats?: FormatName[]; + // (undocumented) + keywords?: boolean; + // (undocumented) + mode?: FormatMode; +} + +// @public (undocumented) +type FormatValidator = (data: T) => boolean; + +// @public (undocumented) +interface FormatsPlugin extends Plugin_2 { + // (undocumented) + get: (format: FormatName, mode?: FormatMode) => Format; +} + +// @public (undocumented) +type FormatsPluginOptions = FormatName[] | FormatOptions; + +// @public (undocumented) +interface FuncKeywordDefinition extends _KeywordDef { + // (undocumented) + async?: boolean; + // (undocumented) + compile?: CompileKeywordFunc; + // (undocumented) + errors?: boolean | "full"; + // (undocumented) + modifying?: boolean; + // (undocumented) + schema?: boolean; + // (undocumented) + valid?: boolean; + // (undocumented) + validate?: SchemaValidateFunction | DataValidateFunction; +} + +// @public (undocumented) +interface InstanceCodeOptions extends CodeOptions { + // (undocumented) + optimize: number; + // (undocumented) + regExp: RegExpEngine; +} + +// @public (undocumented) +type InstanceOptions = Options & RequiredInstanceOptions; + +// @public +type IsElements = false extends IsUnion ? [T] extends [readonly unknown[]] ? undefined extends T[0.5] ? false : true : false : false; + +// @public +type IsEmptyRecord = [T] extends [Record] ? [T] extends [never] ? false : true : false; + +// @public +type IsEnum = null extends EnumString ? false : true; + +// @public +type IsRecord = Union extends IsUnion ? null extends EnumString ? false : true : false; + +// @public (undocumented) +type IsUnion = IsUnion_; + +// @public +type IsUnion_ = false extends (T extends unknown ? ([U] extends [T] ? false : true) : never) ? false : true; + +// @public +type IsValues = false extends IsUnion ? TypeEquality : false; + +// @public (undocumented) +type JSONSchemaType = StrictNullChecksWrapper<"JSONSchemaType", UncheckedJSONSchemaType>; + +// @public (undocumented) +type JSONType = (typeof _jsonTypes)[number]; + +// @public (undocumented) +type JSONType$2 = IsPartial extends true ? T | undefined : T; + +// @public (undocumented) +type JTDDataDef> = +// ref +(S extends { + ref: string; +} ? D extends { [K in S["ref"]]: infer V } ? JTDDataDef : never : S extends { + type: NumberType; +} ? number : S extends { + type: "boolean"; +} ? boolean : S extends { + type: "string"; +} ? string : S extends { + type: "timestamp"; +} ? string | Date : S extends { + enum: readonly (infer E)[]; +} ? string extends E ? never : [E] extends [string] ? E : never : S extends { + elements: infer E; +} ? JTDDataDef[] : S extends { + properties: Record; + optionalProperties?: Record; + additionalProperties?: boolean; +} ? { -readonly [K in keyof S["properties"]]-?: JTDDataDef } & { -readonly [K in keyof S["optionalProperties"]]+?: JTDDataDef } & ([S["additionalProperties"]] extends [true] ? Record : unknown) : S extends { + properties?: Record; + optionalProperties: Record; + additionalProperties?: boolean; +} ? { -readonly [K in keyof S["properties"]]-?: JTDDataDef } & { -readonly [K in keyof S["optionalProperties"]]+?: JTDDataDef } & ([S["additionalProperties"]] extends [true] ? Record : unknown) : S extends { + values: infer V; +} ? Record> : S extends { + discriminator: infer M; + mapping: Record; +} ? [M] extends [string] ? { [K in keyof S["mapping"]]: JTDDataDef & { [KM in M]: K } }[keyof S["mapping"]] : never : unknown) | (S extends { + nullable: true; +} ? null : never); + +// @public (undocumented) +type JTDDataType = S extends { + definitions: Record; +} ? JTDDataDef : JTDDataDef>; + +// @public +type JTDSchemaType = Record> = ( +// refs - where null wasn't specified, must match exactly +(null extends EnumString ? never : ({ [K in keyof D]: [T] extends [D[K]] ? { + ref: K; + } : never }[keyof D] & { + nullable?: false; +}) | (null extends T ? { [K in keyof D]: [Exclude] extends [Exclude] ? { + ref: K; + } : never }[keyof D] & { + nullable: true; +} : never)) | (unknown extends T ? { + nullable?: boolean; +} : never) | ((true extends NullTypeEquality ? { + type: NumberType; +} : true extends NullTypeEquality ? { + type: "boolean"; +} : true extends NullTypeEquality ? { + type: StringType; +} : true extends NullTypeEquality ? { + type: "timestamp"; +} : true extends IsEnum> ? { + enum: EnumString>[]; +} : true extends IsElements> ? T extends readonly (infer E)[] ? { + elements: JTDSchemaType; +} : never : true extends IsEmptyRecord> ? { + properties: Record; + optionalProperties?: Record; +} | { + optionalProperties: Record; +} : true extends IsValues> ? T extends Record ? { + values: JTDSchemaType; +} : never : true extends IsRecord, false> ? ([RequiredKeys>] extends [never] ? { + properties?: Record; +} : { + properties: { [K in RequiredKeys]: JTDSchemaType }; +}) & ([OptionalKeys>] extends [never] ? { + optionalProperties?: Record; +} : { + optionalProperties: { [K in OptionalKeys]: JTDSchemaType, D> }; +}) & { + additionalProperties?: boolean; +} : true extends IsRecord, true> ? { [K in keyof Exclude]-?: Exclude[K] extends string ? { + discriminator: K; + mapping: { [M in Exclude[K]]: JTDSchemaType ? T : never, K>, D> }; + } : never }[keyof Exclude] : never) & (null extends T ? { + nullable: true; +} : { + nullable?: false; +}))) & { + metadata?: Record; + definitions?: { [K in keyof D]: JTDSchemaType }; +}; + +// @public +type JsonSchemaType = JSONSchema.Interface; + +// @public +type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public (undocumented) +class KeywordCxt implements KeywordErrorCxt { + // (undocumented) + readonly $data?: string | false; + // (undocumented) + $dataError(): void; + constructor(it: SchemaObjCxt, def: AddedKeywordDefinition, keyword: string); + // (undocumented) + readonly allErrors?: boolean; + // (undocumented) + block$data(valid: Name, codeBlock: () => void, $dataValid?: Code): void; + // (undocumented) + check$data(valid?: Name, $dataValid?: Code): void; + // (undocumented) + readonly data: Name; + // (undocumented) + readonly def: AddedKeywordDefinition; + // (undocumented) + error(append?: boolean, errorParams?: KeywordCxtParams, errorPaths?: ErrorPaths): void; + // (undocumented) + readonly errsCount?: Name; + // (undocumented) + fail$data(condition: Code): void; + // (undocumented) + fail(condition?: Code): void; + // (undocumented) + failResult(condition: Code, successAction?: () => void, failAction?: () => void): void; + // (undocumented) + readonly gen: CodeGen; + // (undocumented) + invalid$data(): Code; + // (undocumented) + readonly it: SchemaObjCxt; + // (undocumented) + readonly keyword: string; + // (undocumented) + mergeEvaluated(schemaCxt: SchemaCxt, toName?: typeof Name): void; + // (undocumented) + mergeValidEvaluated(schemaCxt: SchemaCxt, valid: Name): boolean | void; + // (undocumented) + ok(cond: Code | boolean): void; + // (undocumented) + params: KeywordCxtParams; + // (undocumented) + readonly parentSchema: AnySchemaObject; + // (undocumented) + pass(condition: Code, failAction?: () => void): void; + // (undocumented) + reset(): void; + // (undocumented) + result(condition: Code, successAction?: () => void, failAction?: () => void): void; + // (undocumented) + schema: any; + // (undocumented) + readonly schemaCode: Code | number | boolean; + // (undocumented) + readonly schemaType: JSONType[]; + // (undocumented) + readonly schemaValue: Code | number | boolean; + // (undocumented) + setParams(obj: KeywordCxtParams, assign?: true): void; + // (undocumented) + subschema(appl: SubschemaArgs, valid: Name): SchemaCxt; +} + +// @public (undocumented) +type KeywordCxtParams = { [P in string]?: Code | string | number }; + +// @public (undocumented) +type KeywordDefinition = CodeKeywordDefinition | FuncKeywordDefinition | MacroKeywordDefinition; + +// @public (undocumented) +interface KeywordErrorCxt { + // (undocumented) + $data?: string | false; + // (undocumented) + data: Name; + // (undocumented) + errsCount?: Name; + // (undocumented) + gen: CodeGen; + // (undocumented) + it: SchemaCxt; + // (undocumented) + keyword: string; + // (undocumented) + params: KeywordCxtParams; + // (undocumented) + parentSchema?: AnySchemaObject; + // (undocumented) + schema: any; + // (undocumented) + schemaCode: Code | number | boolean; + // (undocumented) + schemaType?: JSONType[]; + // (undocumented) + schemaValue: Code | number | boolean; +} + +// @public (undocumented) +interface KeywordErrorDefinition { + // (undocumented) + message: string | Code | ((cxt: KeywordErrorCxt) => string | Code); + // (undocumented) + params?: Code | ((cxt: KeywordErrorCxt) => Code); +} + +// @public (undocumented) +type Known = { + [key: string]: Known; +} | [Known, ...Known[]] | Known[] | number | string | boolean | null; + +// @public (undocumented) +type LocalRefs = { [Ref in string]?: AnySchemaObject }; + +// @public (undocumented) +interface Logger { + // (undocumented) + error(...args: unknown[]): unknown; + // (undocumented) + log(...args: unknown[]): unknown; + // (undocumented) + warn(...args: unknown[]): unknown; +} + +// @public (undocumented) +interface MacroKeywordDefinition extends FuncKeywordDefinition { + // (undocumented) + macro: MacroKeywordFunc; +} + +// @public (undocumented) +type MacroKeywordFunc = (schema: any, parentSchema: AnySchemaObject, it: SchemaCxt) => AnySchema; + +// @public (undocumented) +class MissingRefError extends Error { + constructor(resolver: UriResolver, baseId: string, ref: string, msg?: string); + // (undocumented) + readonly missingRef: string; + // (undocumented) + readonly missingSchema: string; +} + +// @public (undocumented) +class Name extends _CodeOrName { + constructor(s: string); + // (undocumented) + emptyStr(): boolean; + // (undocumented) + get names(): UsedNames; + // (undocumented) + readonly str: string; + // (undocumented) + toString(): string; +} + +// @public (undocumented) +interface NameGroup { + // (undocumented) + index: number; + // (undocumented) + prefix: string; +} + +// @public (undocumented) +interface NameValue { + // (undocumented) + code?: Code; + // (undocumented) + key?: unknown; + // (undocumented) + ref: ValueReference; +} + +// @public +type NullTypeEquality = TypeEquality; + +// @public (undocumented) +type Nullable = undefined extends T ? { + nullable: true; + const?: null; + enum?: readonly (T | null)[]; + default?: T | null; +} : { + nullable?: false; + const?: T; + enum?: readonly T[]; + default?: T; +}; + +// @public (undocumented) +interface NumberKeywords { + // (undocumented) + exclusiveMaximum?: number; + // (undocumented) + exclusiveMinimum?: number; + // (undocumented) + format?: string; + // (undocumented) + maximum?: number; + // (undocumented) + minimum?: number; + // (undocumented) + multipleOf?: number; +} + +// @public +type NumberType = "float32" | "float64" | "int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32"; + +// @public +type OptionalKeys = { [K in keyof T]-?: undefined extends T[K] ? K : never }[keyof T]; + +// @public (undocumented) +type Options = CurrentOptions & DeprecatedOptions; + +// @public (undocumented) +interface Plugin_2 { + // (undocumented) + (ajv: Ajv$2, options?: Opts): Ajv$2; + // (undocumented) + [prop: string]: any; +} + +// @public (undocumented) +interface RegExpEngine { + // (undocumented) + (pattern: string, u: string): RegExpLike; + // (undocumented) + code: string; +} + +// @public (undocumented) +interface RegExpLike { + // (undocumented) + test: (s: string) => boolean; +} + +// @public (undocumented) +type RequiredInstanceOptions = { [K in "strictSchema" | "strictNumbers" | "strictTypes" | "strictTuples" | "strictRequired" | "inlineRefs" | "loopRequired" | "loopEnum" | "meta" | "messages" | "schemaId" | "addUsedSchema" | "validateSchema" | "validateFormats" | "int32range" | "unicodeRegExp" | "uriResolver"]: NonNullable } & { + code: InstanceCodeOptions; +}; + +// @public +type RequiredKeys = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]; + +// @public (undocumented) +interface Rule { + // (undocumented) + definition: AddedKeywordDefinition; + // (undocumented) + keyword: string; +} + +// @public (undocumented) +interface RuleGroup { + // (undocumented) + rules: Rule[]; + // (undocumented) + type?: JSONType; +} + +// @public (undocumented) +type SafeExpr = Code | number | boolean | null; + +// @public (undocumented) +type Schema = SchemaObject | boolean; + +// @public (undocumented) +interface SchemaCxt { + // (undocumented) + readonly allErrors?: boolean; + // (undocumented) + baseId: string; + // (undocumented) + readonly compositeRule?: boolean; + // (undocumented) + readonly createErrors?: boolean; + // (undocumented) + readonly data: Name; + // (undocumented) + readonly dataLevel: number; + // (undocumented) + readonly dataNames: Name[]; + // (undocumented) + readonly dataPathArr: (Code | number)[]; + // (undocumented) + dataTypes: JSONType[]; + // (undocumented) + definedProperties: Set; + // (undocumented) + readonly errorPath: Code; + // (undocumented) + readonly errSchemaPath: string; + // (undocumented) + evaluated?: Name; + // (undocumented) + readonly gen: CodeGen; + // (undocumented) + items?: EvaluatedItems | Name; + // (undocumented) + jtdDiscriminator?: string; + // (undocumented) + jtdMetadata?: boolean; + // (undocumented) + readonly opts: InstanceOptions; + // (undocumented) + readonly parentData: Name; + // (undocumented) + readonly parentDataProperty: Code | number; + // (undocumented) + readonly propertyName?: Name; + // (undocumented) + props?: EvaluatedProperties | Name; + // (undocumented) + readonly rootId: string; + // (undocumented) + readonly schema: AnySchema; + // (undocumented) + readonly schemaEnv: SchemaEnv; + // (undocumented) + readonly schemaPath: Code; + // (undocumented) + readonly self: Ajv$2; + // (undocumented) + readonly topSchemaRef: Code; + // (undocumented) + readonly validateName: Name; + // (undocumented) + readonly ValidationError?: Name; +} + +// @public (undocumented) +class SchemaEnv implements SchemaEnvArgs { + // (undocumented) + readonly $async?: boolean; + constructor(env: SchemaEnvArgs); + // (undocumented) + baseId: string; + // (undocumented) + readonly dynamicAnchors: { [Ref in string]?: true }; + // (undocumented) + localRefs?: LocalRefs; + // (undocumented) + readonly meta?: boolean; + // (undocumented) + parse?: (data: string) => unknown; + // (undocumented) + parseName?: ValueScopeName; + // (undocumented) + readonly refs: SchemaRefs; + // (undocumented) + readonly root: SchemaEnv; + // (undocumented) + readonly schema: AnySchema; + // (undocumented) + readonly schemaId?: "$id" | "id"; + // (undocumented) + schemaPath?: string; + // (undocumented) + serialize?: (data: unknown) => string; + // (undocumented) + serializeName?: ValueScopeName; + // (undocumented) + validate?: AnyValidateFunction; + // (undocumented) + validateName?: ValueScopeName; +} + +// @public (undocumented) +interface SchemaEnvArgs { + // (undocumented) + readonly baseId?: string; + // (undocumented) + readonly localRefs?: LocalRefs; + // (undocumented) + readonly meta?: boolean; + // (undocumented) + readonly root?: SchemaEnv; + // (undocumented) + readonly schema: AnySchema; + // (undocumented) + readonly schemaId?: "$id" | "id"; + // (undocumented) + readonly schemaPath?: string; +} + +// @public (undocumented) +interface SchemaObjCxt extends SchemaCxt { + // (undocumented) + readonly schema: AnySchemaObject; +} + +// @public (undocumented) +interface SchemaObject extends _SchemaObject { + // (undocumented) + $async?: false; + // (undocumented) + $id?: string; + // (undocumented) + $schema?: string; + // (undocumented) + [x: string]: any; + // (undocumented) + id?: string; +} + +// @public (undocumented) +type SchemaRefs = { [Ref in string]?: SchemaEnv | AnySchema }; + +// @public (undocumented) +interface SchemaValidateFunction { + // (undocumented) + (schema: any, data: any, parentSchema?: AnySchemaObject, dataCxt?: DataValidationCxt): boolean | Promise; + // (undocumented) + errors?: Partial[]; +} + +// @public (undocumented) +class Scope { + constructor(input?: ScopeOptions); + // (undocumented) + name(prefix: string): Name; + // (undocumented) + protected readonly _names: { [Prefix in string]?: NameGroup }; + // (undocumented) + protected _newName(prefix: string): string; + // (undocumented) + protected readonly _parent?: Scope; + // (undocumented) + protected readonly _prefixes?: Set; + // (undocumented) + toName(nameOrPrefix: Name | string): Name; +} + +// @public (undocumented) +interface ScopeOptions { + // (undocumented) + parent?: Scope; + // (undocumented) + prefixes?: Set; +} + +// @public (undocumented) +interface ScopePath { + // (undocumented) + itemIndex: number; + // (undocumented) + property: string; +} + +// @public (undocumented) +type ScopeStore = Record; + +// @public (undocumented) +type ScopeValueSets = { [Prefix in string]?: Set }; + +// @public (undocumented) +type ScopeValues = { [Prefix in string]?: Map }; + +// @public +type SomeJTDSchemaType = ( +// ref + { + ref: string; +} | { + type: NumberType | StringType | "boolean"; +} | { + enum: string[]; +} | { + elements: SomeJTDSchemaType; +} | { + values: SomeJTDSchemaType; +} | { + properties: Record; + optionalProperties?: Record; + additionalProperties?: boolean; +} | { + properties?: Record; + optionalProperties: Record; + additionalProperties?: boolean; +} | { + discriminator: string; + mapping: Record; +} | {}) & { + nullable?: boolean; + metadata?: Record; + definitions?: Record; +}; + +// @public (undocumented) +interface SourceCode { + // (undocumented) + evaluated?: Code; + // (undocumented) + scopeValues: ScopeValueSets; + // (undocumented) + validateCode: string; + // (undocumented) + validateName: ValueScopeName; +} + +// @public (undocumented) +type StrictNullChecksWrapper = undefined extends null ? `strictNullChecks must be true in tsconfig to use ${Name}` : Type; + +// @public (undocumented) +interface StringKeywords { + // (undocumented) + format?: string; + // (undocumented) + maxLength?: number; + // (undocumented) + minLength?: number; + // (undocumented) + pattern?: string; +} + +// @public +type StringType = "string" | "timestamp"; + +// @public (undocumented) +type SubschemaArgs = Partial<{ + keyword: string; + schemaProp: string | number; + schema: AnySchema; + schemaPath: Code; + errSchemaPath: string; + topSchemaRef: Code; + data: Name | Code; + dataProp: Code | string | number; + dataTypes: JSONType[]; + definedProperties: Set; + propertyName: Name; + dataPropType: Type; + jtdDiscriminator: string; + jtdMetadata: boolean; + compositeRule: true; + createErrors: boolean; + allErrors: boolean; +}>; + +// @public (undocumented) +enum Type { + // (undocumented) + Num = 0, + // (undocumented) + Str = 1, +} + +// @public +type TypeEquality = [T] extends [E] ? ([E] extends [T] ? true : false) : false; + +// @public (undocumented) +type UncheckedJSONSchemaType = ( +// these two unions allow arbitrary unions of types + { + anyOf: readonly UncheckedJSONSchemaType[]; +} | { + oneOf: readonly UncheckedJSONSchemaType[]; +} | ({ + type: readonly (T extends number ? JSONType$2<"number" | "integer", IsPartial> : T extends string ? JSONType$2<"string", IsPartial> : T extends boolean ? JSONType$2<"boolean", IsPartial> : never)[]; +} & UnionToIntersection) | ((T extends number ? { + type: JSONType$2<"number" | "integer", IsPartial>; +} & NumberKeywords : T extends string ? { + type: JSONType$2<"string", IsPartial>; +} & StringKeywords : T extends boolean ? { + type: JSONType$2<"boolean", IsPartial>; +} : T extends readonly [any, ...any[]] ? { + type: JSONType$2<"array", IsPartial>; + items: { readonly [K in keyof T]-?: UncheckedJSONSchemaType & Nullable } & { + length: T["length"]; + }; + minItems: T["length"]; +} & ({ + maxItems: T["length"]; +} | { + additionalItems: false; +}) : T extends readonly any[] ? { + type: JSONType$2<"array", IsPartial>; + items: UncheckedJSONSchemaType; + contains?: UncheckedPartialSchema; + minItems?: number; + maxItems?: number; + minContains?: number; + maxContains?: number; + uniqueItems?: true; + additionalItems?: never; +} : T extends Record ? { + type: JSONType$2<"object", IsPartial>; + additionalProperties?: boolean | UncheckedJSONSchemaType; + unevaluatedProperties?: boolean | UncheckedJSONSchemaType; + properties?: IsPartial extends true ? Partial> : UncheckedPropertiesSchema; + patternProperties?: Record>; + propertyNames?: Omit, "type"> & { + type?: "string"; + }; + dependencies?: { [K in keyof T]?: readonly (keyof T)[] | UncheckedPartialSchema }; + dependentRequired?: { [K in keyof T]?: readonly (keyof T)[] }; + dependentSchemas?: { [K in keyof T]?: UncheckedPartialSchema }; + minProperties?: number; + maxProperties?: number; +} & (IsPartial extends true ? { + required: readonly (keyof T)[]; +} : [UncheckedRequiredMembers] extends [never] ? { + required?: readonly UncheckedRequiredMembers[]; +} : { + required: readonly UncheckedRequiredMembers[]; +}) : T extends null ? { + type: JSONType$2<"null", IsPartial>; + nullable: true; +} : never) & { + allOf?: readonly UncheckedPartialSchema[]; + anyOf?: readonly UncheckedPartialSchema[]; + oneOf?: readonly UncheckedPartialSchema[]; + if?: UncheckedPartialSchema; + then?: UncheckedPartialSchema; + else?: UncheckedPartialSchema; + not?: UncheckedPartialSchema; +})) & { + [keyword: string]: any; + $id?: string; + $ref?: string; + $defs?: Record>; + definitions?: Record>; +}; + +// @public (undocumented) +type UncheckedPartialSchema = Partial>; + +// @public (undocumented) +type UncheckedPropertiesSchema = { [K in keyof T]-?: (UncheckedJSONSchemaType & Nullable) | { + $ref: string; + } }; + +// @public (undocumented) +type UncheckedRequiredMembers = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]; + +// @public (undocumented) +type UnionToIntersection = (U extends any ? (_: U) => void : never) extends ((_: infer I) => void) ? I : never; + +// @public (undocumented) +interface UriResolver { + // (undocumented) + parse(uri: string): URIComponent; + // (undocumented) + resolve(base: string, path: string): string; + // (undocumented) + serialize(component: URIComponent): string; +} + +// @public (undocumented) +type UsedNames = Record; + +// @public (undocumented) +type UsedScopeValues = { [Prefix in string]?: Map }; + +// @public (undocumented) +enum UsedValueState { + // (undocumented) + Completed = 1, + // (undocumented) + Started = 0, +} + +// @public (undocumented) +interface VSOptions extends ValueScopeOptions { + // (undocumented) + _n: Code; +} + +// @public (undocumented) +interface ValidateFunction { + // (undocumented) + (this: Ajv$2 | any, data: any, dataCxt?: DataValidationCxt): data is T; + // (undocumented) + errors?: null | ErrorObject[]; + // (undocumented) + evaluated?: Evaluated; + // (undocumented) + schema: AnySchema; + // (undocumented) + schemaEnv: SchemaEnv; + // (undocumented) + source?: SourceCode; +} + +// @public (undocumented) +class ValidationError extends Error { + constructor(errors: Partial[]); + // (undocumented) + readonly ajv: true; + // (undocumented) + readonly errors: Partial[]; + // (undocumented) + readonly validation: true; +} + +// @public (undocumented) +interface ValidationRules { + // (undocumented) + all: { [Key in string]?: boolean | Rule }; + // (undocumented) + keywords: { [Key in string]?: boolean }; + // (undocumented) + post: RuleGroup; + // (undocumented) + rules: RuleGroup[]; + // (undocumented) + types: ValidationTypes; +} + +// @public (undocumented) +type ValidationTypes = { [K in JSONType]: boolean | RuleGroup | undefined }; + +// @public (undocumented) +type ValueReference = unknown; + +// @public (undocumented) +class ValueScope extends Scope { + constructor(opts: ValueScopeOptions); + // (undocumented) + get(): ScopeStore; + // (undocumented) + getValue(prefix: string, keyOrRef: unknown): ValueScopeName | undefined; + // (undocumented) + name(prefix: string): ValueScopeName; + // (undocumented) + readonly opts: VSOptions; + // (undocumented) + protected readonly _scope: ScopeStore; + // (undocumented) + scopeCode(values?: ScopeValues | ScopeValueSets, usedValues?: UsedScopeValues, getCode?: (n: ValueScopeName) => Code | undefined): Code; + // (undocumented) + scopeRefs(scopeName: Name, values?: ScopeValues | ScopeValueSets): Code; + // (undocumented) + value(nameOrPrefix: ValueScopeName | string, value: NameValue): ValueScopeName; + // (undocumented) + protected readonly _values: ScopeValues; +} + +// @public (undocumented) +class ValueScopeName extends Name { + constructor(prefix: string, nameStr: string); + // (undocumented) + readonly prefix: string; + // (undocumented) + scopePath?: Code; + // (undocumented) + setValue(value: NameValue, input: ScopePath): void; + // (undocumented) + value?: NameValue; +} + +// @public (undocumented) +interface ValueScopeOptions extends ScopeOptions { + // (undocumented) + es5?: boolean; + // (undocumented) + lines?: boolean; + // (undocumented) + scope: ScopeStore; +} + +// @public (undocumented) +type Vocabulary = (KeywordDefinition | string)[]; + +// @public (undocumented) +class _Code extends _CodeOrName { + constructor(code: string | readonly CodeItem[]); + // (undocumented) + emptyStr(): boolean; + // (undocumented) + readonly _items: readonly CodeItem[]; + // (undocumented) + get names(): UsedNames; + // (undocumented) + get str(): string; + // (undocumented) + toString(): string; +} + +// @public (undocumented) +abstract class _CodeOrName { + // (undocumented) + abstract emptyStr(): boolean; + // (undocumented) + abstract readonly names: UsedNames; + // (undocumented) + abstract readonly str: string; + // (undocumented) + abstract toString(): string; +} + +// @public (undocumented) +interface _KeywordDef { + // (undocumented) + $data?: boolean; + // (undocumented) + $dataError?: KeywordErrorDefinition; + // (undocumented) + allowUndefined?: boolean; + // (undocumented) + before?: string; + // (undocumented) + dependencies?: string[]; + // (undocumented) + error?: KeywordErrorDefinition; + // (undocumented) + implements?: string[]; + // (undocumented) + keyword: string | string[]; + // (undocumented) + metaSchema?: AnySchemaObject; + // (undocumented) + post?: boolean; + // (undocumented) + schemaType?: JSONType | JSONType[]; + // (undocumented) + type?: JSONType | JSONType[]; + // (undocumented) + validateSchema?: AnyValidateFunction; +} + +// @public (undocumented) +interface _SchemaObject { + // (undocumented) + $id?: string; + // (undocumented) + $schema?: string; + // (undocumented) + [x: string]: any; + // (undocumented) + id?: string; +} + +// @public (undocumented) +const _jsonTypes: readonly ["string", "number", "integer", "boolean", "null", "object", "array"]; + +// @public +export const addFormats: typeof formatsPlugin.default; + +// @public (undocumented) +const formatsPlugin: FormatsPlugin; + +// @public +interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/client/etc/client.validators-cf-worker.api.md b/packages/client/etc/client.validators-cf-worker.api.md new file mode 100644 index 0000000000..c9cc131279 --- /dev/null +++ b/packages/client/etc/client.validators-cf-worker.api.md @@ -0,0 +1,44 @@ +## API Report File for "@modelcontextprotocol/client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JSONSchema } from 'json-schema-typed'; + +// @public +export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { + constructor(options?: { + shortcircuit?: boolean; + draft?: CfWorkerSchemaDraft; + }); + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; + +// @public +type JsonSchemaType = JSONSchema.Interface; + +// @public +type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public +interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/middleware/express/etc/express.api.md b/packages/middleware/express/etc/express.api.md new file mode 100644 index 0000000000..5cd7c35860 --- /dev/null +++ b/packages/middleware/express/etc/express.api.md @@ -0,0 +1,95 @@ +## API Report File for "@modelcontextprotocol/express" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Express as Express_2 } from 'express'; +import { RequestHandler } from 'express'; +import { Router } from 'express'; +import * as z from 'zod/v4'; + +// @public +interface AuthInfo { + clientId: string; + expiresAt?: number; + extra?: Record; + resource?: URL; + scopes: string[]; + token: string; +} + +// @public +export interface AuthMetadataOptions { + oauthMetadata: OAuthMetadata; + resourceName?: string; + resourceServerUrl: URL; + scopesSupported?: string[]; + serviceDocumentationUrl?: URL; +} + +// @public +export interface BearerAuthMiddlewareOptions { + requiredScopes?: string[]; + resourceMetadataUrl?: string; + verifier: OAuthTokenVerifier; +} + +// @public +export interface CreateMcpExpressAppOptions { + allowedHosts?: string[]; + host?: string; + jsonLimit?: string; +} + +// @public +type OAuthMetadata = z.infer; + +// @public +const OAuthMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + revocation_endpoint: z.ZodOptional; + revocation_endpoint_auth_methods_supported: z.ZodOptional>; + revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + introspection_endpoint: z.ZodOptional; + introspection_endpoint_auth_methods_supported: z.ZodOptional>; + introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + code_challenge_methods_supported: z.ZodOptional>; + client_id_metadata_document_supported: z.ZodOptional; +}, z.core.$loose>; + +// @public +export interface OAuthTokenVerifier { + verifyAccessToken(token: string): Promise; +} + +// @public +export function createMcpExpressApp(options?: CreateMcpExpressAppOptions): Express_2; + +// @public +export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string; + +// @public +export function hostHeaderValidation(allowedHostnames: string[]): RequestHandler; + +// @public +export function localhostHostValidation(): RequestHandler; + +// @public +export function mcpAuthMetadataRouter(options: AuthMetadataOptions): Router; + +// @public +export function requireBearerAuth(input: BearerAuthMiddlewareOptions): RequestHandler; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/middleware/fastify/etc/fastify.api.md b/packages/middleware/fastify/etc/fastify.api.md new file mode 100644 index 0000000000..718d77e99e --- /dev/null +++ b/packages/middleware/fastify/etc/fastify.api.md @@ -0,0 +1,27 @@ +## API Report File for "@modelcontextprotocol/fastify" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { FastifyInstance } from 'fastify'; +import { FastifyReply } from 'fastify'; +import { FastifyRequest } from 'fastify'; + +// @public +export interface CreateMcpFastifyAppOptions { + allowedHosts?: string[]; + host?: string; +} + +// @public +export function createMcpFastifyApp(options?: CreateMcpFastifyAppOptions): FastifyInstance; + +// @public +export function hostHeaderValidation(allowedHostnames: string[]): (request: FastifyRequest, reply: FastifyReply) => Promise; + +// @public +export function localhostHostValidation(): (request: FastifyRequest, reply: FastifyReply) => Promise; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/middleware/hono/etc/hono.api.md b/packages/middleware/hono/etc/hono.api.md new file mode 100644 index 0000000000..e3266bd9e0 --- /dev/null +++ b/packages/middleware/hono/etc/hono.api.md @@ -0,0 +1,26 @@ +## API Report File for "@modelcontextprotocol/hono" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Hono } from 'hono'; +import { MiddlewareHandler } from 'hono'; + +// @public +export interface CreateMcpHonoAppOptions { + allowedHosts?: string[]; + host?: string; +} + +// @public +export function createMcpHonoApp(options?: CreateMcpHonoAppOptions): Hono; + +// @public +export function hostHeaderValidation(allowedHostnames: string[]): MiddlewareHandler; + +// @public +export function localhostHostValidation(): MiddlewareHandler; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/middleware/node/etc/node.api.md b/packages/middleware/node/etc/node.api.md new file mode 100644 index 0000000000..4072252d77 --- /dev/null +++ b/packages/middleware/node/etc/node.api.md @@ -0,0 +1,175 @@ +## API Report File for "@modelcontextprotocol/node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { IncomingMessage } from 'node:http'; +import { ServerResponse } from 'node:http'; +import * as z from 'zod/v4'; + +// @public +interface AuthInfo { + clientId: string; + expiresAt?: number; + extra?: Record; + resource?: URL; + scopes: string[]; + token: string; +} + +// @public (undocumented) +type EventId = string; + +// @public +interface EventStore { + getStreamIdForEventId?(eventId: EventId): Promise; + // (undocumented) + replayEventsAfter(lastEventId: EventId, input: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise; + }): Promise; + storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; +} + +// @public (undocumented) +type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; + +// @public (undocumented) +type Infer = Flatten>; + +// @public (undocumented) +type JSONRPCMessage = Infer; + +// @public (undocumented) +const JSONRPCMessageSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; +}, z.core.$strict>, z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>]>; + +// @public +interface MessageExtraInfo { + authInfo?: AuthInfo; + closeSSEStream?: () => void; + closeStandaloneSSEStream?: () => void; + request?: globalThis.Request; +} + +// @public +export class NodeStreamableHTTPServerTransport implements Transport { + constructor(options?: StreamableHTTPServerTransportOptions); + close(): Promise; + closeSSEStream(requestId: RequestId): void; + closeStandaloneSSEStream(): void; + handleRequest(req: IncomingMessage & { + auth?: AuthInfo; + }, res: ServerResponse, parsedBody?: unknown): Promise; + set onclose(handler: (() => void) | undefined); + // (undocumented) + get onclose(): (() => void) | undefined; + set onerror(handler: ((error: Error) => void) | undefined); + // (undocumented) + get onerror(): ((error: Error) => void) | undefined; + set onmessage(handler: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined); + // (undocumented) + get onmessage(): ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined; + send(message: JSONRPCMessage, options?: { + relatedRequestId?: RequestId; + }): Promise; + get sessionId(): string | undefined; + start(): Promise; +} + +// @public (undocumented) +type Primitive = string | number | boolean | bigint | null | undefined; + +// @public (undocumented) +type RequestId = Infer; + +// @public +const RequestIdSchema: z.ZodUnion; + +// @public (undocumented) +type StreamId = string; + +// @public +export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServerTransportOptions; + +// @public +interface Transport { + close(): Promise; + onclose?: (() => void) | undefined; + onerror?: ((error: Error) => void) | undefined; + onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; + sessionId?: string | undefined; + setProtocolVersion?: ((version: string) => void) | undefined; + setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; + start(): Promise; +} + +// @public +type TransportSendOptions = { + relatedRequestId?: RequestId | undefined; + resumptionToken?: string | undefined; + onresumptiontoken?: ((token: string) => void) | undefined; +}; + +// @public +interface WebStandardStreamableHTTPServerTransportOptions { + // @deprecated + allowedHosts?: string[]; + // @deprecated + allowedOrigins?: string[]; + // @deprecated + enableDnsRebindingProtection?: boolean; + enableJsonResponse?: boolean; + eventStore?: EventStore; + onsessionclosed?: ((sessionId: string) => void | Promise) | undefined; + onsessioninitialized?: ((sessionId: string) => void | Promise) | undefined; + retryInterval?: number; + sessionIdGenerator?: (() => string) | undefined; + supportedProtocolVersions?: string[]; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/server-legacy/etc/server-legacy.api.md b/packages/server-legacy/etc/server-legacy.api.md new file mode 100644 index 0000000000..8b0a2b00bf --- /dev/null +++ b/packages/server-legacy/etc/server-legacy.api.md @@ -0,0 +1,588 @@ +## API Report File for "@modelcontextprotocol/server-legacy" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import express from 'express'; +import { IncomingMessage } from 'node:http'; +import { Options } from 'express-rate-limit'; +import { RequestHandler } from 'express'; +import { Response as Response_2 } from 'express'; +import { ServerResponse } from 'node:http'; +import * as z from 'zod/v4'; + +// @public +export class AccessDeniedError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export interface AuthInfo { + clientId: string; + expiresAt?: number; + extra?: Record; + resource?: URL; + scopes: string[]; + token: string; +} + +// @public (undocumented) +export type AuthMetadataOptions = { + oauthMetadata: OAuthMetadata; + resourceServerUrl: URL; + serviceDocumentationUrl?: URL; + scopesSupported?: string[]; + resourceName?: string; +}; + +// @public (undocumented) +export type AuthRouterOptions = { + provider: OAuthServerProvider; + issuerUrl: URL; + baseUrl?: URL; + serviceDocumentationUrl?: URL; + scopesSupported?: string[]; + resourceName?: string; + resourceServerUrl?: URL; + authorizationOptions?: Omit; + clientRegistrationOptions?: Omit; + revocationOptions?: Omit; + tokenOptions?: Omit; +}; + +// @public (undocumented) +export type AuthorizationHandlerOptions = { + provider: OAuthServerProvider; + rateLimit?: Partial | false; +}; + +// @public (undocumented) +export type AuthorizationParams = { + state?: string; + scopes?: string[]; + codeChallenge: string; + redirectUri: string; + resource?: URL; +}; + +// @public (undocumented) +export type BearerAuthMiddlewareOptions = { + verifier: OAuthTokenVerifier; + requiredScopes?: string[]; + resourceMetadataUrl?: string; +}; + +// @public (undocumented) +export type ClientAuthenticationMiddlewareOptions = { + clientsStore: OAuthRegisteredClientsStore; +}; + +// @public (undocumented) +export type ClientRegistrationHandlerOptions = { + clientsStore: OAuthRegisteredClientsStore; + clientSecretExpirySeconds?: number; + rateLimit?: Partial | false; + clientIdGeneration?: boolean; +}; + +// @public +export class CustomOAuthError extends OAuthError { + constructor(customErrorCode: string, message: string, errorUri?: string); + // (undocumented) + get errorCode(): string; +} + +// @public (undocumented) +type FetchLike = (url: string | URL, init?: RequestInit) => Promise; + +// @public (undocumented) +type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; + +// @public (undocumented) +type Infer = Flatten>; + +// @public +export class InsufficientScopeError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidClientError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidClientMetadataError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidGrantError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidRequestError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidScopeError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidTargetError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidTokenError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public (undocumented) +type JSONRPCMessage = Infer; + +// @public (undocumented) +const JSONRPCMessageSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; +}, z.core.$strict>, z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>]>; + +// @public +interface MessageExtraInfo { + authInfo?: AuthInfo; + closeSSEStream?: () => void; + closeStandaloneSSEStream?: () => void; + request?: globalThis.Request; +} + +// @public +export class MethodNotAllowedError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export const OAUTH_ERRORS: { + readonly [InvalidRequestError.errorCode]: typeof InvalidRequestError; + readonly [InvalidClientError.errorCode]: typeof InvalidClientError; + readonly [InvalidGrantError.errorCode]: typeof InvalidGrantError; + readonly [UnauthorizedClientError.errorCode]: typeof UnauthorizedClientError; + readonly [UnsupportedGrantTypeError.errorCode]: typeof UnsupportedGrantTypeError; + readonly [InvalidScopeError.errorCode]: typeof InvalidScopeError; + readonly [AccessDeniedError.errorCode]: typeof AccessDeniedError; + readonly [ServerError.errorCode]: typeof ServerError; + readonly [TemporarilyUnavailableError.errorCode]: typeof TemporarilyUnavailableError; + readonly [UnsupportedResponseTypeError.errorCode]: typeof UnsupportedResponseTypeError; + readonly [UnsupportedTokenTypeError.errorCode]: typeof UnsupportedTokenTypeError; + readonly [InvalidTokenError.errorCode]: typeof InvalidTokenError; + readonly [MethodNotAllowedError.errorCode]: typeof MethodNotAllowedError; + readonly [TooManyRequestsError.errorCode]: typeof TooManyRequestsError; + readonly [InvalidClientMetadataError.errorCode]: typeof InvalidClientMetadataError; + readonly [InsufficientScopeError.errorCode]: typeof InsufficientScopeError; + readonly [InvalidTargetError.errorCode]: typeof InvalidTargetError; +}; + +// @public (undocumented) +type OAuthClientInformationFull = z.infer; + +// @public +const OAuthClientInformationFullSchema: z.ZodObject<{ + redirect_uris: z.ZodArray; + token_endpoint_auth_method: z.ZodOptional; + grant_types: z.ZodOptional>; + response_types: z.ZodOptional>; + client_name: z.ZodOptional; + client_uri: z.ZodOptional; + logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + scope: z.ZodOptional; + contacts: z.ZodOptional>; + tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + policy_uri: z.ZodOptional; + jwks_uri: z.ZodOptional; + jwks: z.ZodOptional; + software_id: z.ZodOptional; + software_version: z.ZodOptional; + software_statement: z.ZodOptional; + client_id: z.ZodString; + client_secret: z.ZodOptional; + client_id_issued_at: z.ZodOptional; + client_secret_expires_at: z.ZodOptional; +}, z.core.$strip>; + +// @public +export class OAuthError extends Error { + constructor(message: string, errorUri?: string | undefined); + // (undocumented) + static errorCode: string; + // (undocumented) + get errorCode(): string; + // (undocumented) + readonly errorUri?: string | undefined; + toResponseObject(): OAuthErrorResponse; +} + +// @public (undocumented) +type OAuthErrorResponse = z.infer; + +// @public +const OAuthErrorResponseSchema: z.ZodObject<{ + error: z.ZodString; + error_description: z.ZodOptional; + error_uri: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +type OAuthMetadata = z.infer; + +// @public +const OAuthMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + revocation_endpoint: z.ZodOptional; + revocation_endpoint_auth_methods_supported: z.ZodOptional>; + revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + introspection_endpoint: z.ZodOptional; + introspection_endpoint_auth_methods_supported: z.ZodOptional>; + introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + code_challenge_methods_supported: z.ZodOptional>; + client_id_metadata_document_supported: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +type OAuthProtectedResourceMetadata = z.infer; + +// @public +const OAuthProtectedResourceMetadataSchema: z.ZodObject<{ + resource: z.ZodString; + authorization_servers: z.ZodOptional>; + jwks_uri: z.ZodOptional; + scopes_supported: z.ZodOptional>; + bearer_methods_supported: z.ZodOptional>; + resource_signing_alg_values_supported: z.ZodOptional>; + resource_name: z.ZodOptional; + resource_documentation: z.ZodOptional; + resource_policy_uri: z.ZodOptional; + resource_tos_uri: z.ZodOptional; + tls_client_certificate_bound_access_tokens: z.ZodOptional; + authorization_details_types_supported: z.ZodOptional>; + dpop_signing_alg_values_supported: z.ZodOptional>; + dpop_bound_access_tokens_required: z.ZodOptional; +}, z.core.$loose>; + +// @public +export interface OAuthRegisteredClientsStore { + getClient(clientId: string): OAuthClientInformationFull | undefined | Promise; + registerClient?(client: Omit): OAuthClientInformationFull | Promise; +} + +// @public +export interface OAuthServerProvider { + authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response_2): Promise; + challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; + get clientsStore(): OAuthRegisteredClientsStore; + exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, redirectUri?: string, resource?: URL): Promise; + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; + revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; + skipLocalPkceValidation?: boolean; + verifyAccessToken(token: string): Promise; +} + +// @public (undocumented) +type OAuthTokenRevocationRequest = z.infer; + +// @public +const OAuthTokenRevocationRequestSchema: z.ZodObject<{ + token: z.ZodString; + token_type_hint: z.ZodOptional; +}, z.core.$strip>; + +// @public +export interface OAuthTokenVerifier { + verifyAccessToken(token: string): Promise; +} + +// @public (undocumented) +type OAuthTokens = z.infer; + +// @public +const OAuthTokensSchema: z.ZodObject<{ + access_token: z.ZodString; + id_token: z.ZodOptional; + token_type: z.ZodString; + expires_in: z.ZodOptional>; + scope: z.ZodOptional; + refresh_token: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +type Primitive = string | number | boolean | bigint | null | undefined; + +// @public (undocumented) +export type ProxyEndpoints = { + authorizationUrl: string; + tokenUrl: string; + revocationUrl?: string; + registrationUrl?: string; +}; + +// @public +export class ProxyOAuthServerProvider implements OAuthServerProvider { + constructor(options: ProxyOptions); + // (undocumented) + authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response_2): Promise; + // (undocumented) + challengeForAuthorizationCode(_client: OAuthClientInformationFull, _authorizationCode: string): Promise; + // (undocumented) + get clientsStore(): OAuthRegisteredClientsStore; + // (undocumented) + protected readonly _endpoints: ProxyEndpoints; + // (undocumented) + exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, redirectUri?: string, resource?: URL): Promise; + // (undocumented) + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; + // (undocumented) + protected readonly _fetch?: FetchLike; + // (undocumented) + protected readonly _getClient: (clientId: string) => Promise; + // (undocumented) + revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise; + // (undocumented) + skipLocalPkceValidation: boolean; + // (undocumented) + verifyAccessToken(token: string): Promise; + // (undocumented) + protected readonly _verifyAccessToken: (token: string) => Promise; +} + +// @public (undocumented) +export type ProxyOptions = { + endpoints: ProxyEndpoints; + verifyAccessToken: (token: string) => Promise; + getClient: (clientId: string) => Promise; + fetch?: FetchLike; +}; + +// @public (undocumented) +type RequestId = Infer; + +// @public +const RequestIdSchema: z.ZodUnion; + +// @public (undocumented) +export type RevocationHandlerOptions = { + provider: OAuthServerProvider; + rateLimit?: Partial | false; +}; + +// @public @deprecated +export class SSEServerTransport implements Transport { + constructor(_endpoint: string, res: ServerResponse, options?: SSEServerTransportOptions); + // (undocumented) + close(): Promise; + // (undocumented) + handleMessage(message: unknown, extra?: MessageExtraInfo): Promise; + // (undocumented) + handlePostMessage(req: IncomingMessage & { + auth?: AuthInfo; + }, res: ServerResponse, parsedBody?: unknown): Promise; + // (undocumented) + onclose?: () => void; + // (undocumented) + onerror?: ((error: Error) => void) | undefined; + // (undocumented) + onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + // (undocumented) + send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise; + // (undocumented) + get sessionId(): string; + // (undocumented) + start(): Promise; +} + +// @public @deprecated +export interface SSEServerTransportOptions { + // @deprecated (undocumented) + allowedHosts?: string[]; + // @deprecated (undocumented) + allowedOrigins?: string[]; + // @deprecated (undocumented) + enableDnsRebindingProtection?: boolean; +} + +// @public +export class ServerError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class TemporarilyUnavailableError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public (undocumented) +export type TokenHandlerOptions = { + provider: OAuthServerProvider; + rateLimit?: Partial | false; +}; + +// @public +export class TooManyRequestsError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +interface Transport { + close(): Promise; + onclose?: (() => void) | undefined; + onerror?: ((error: Error) => void) | undefined; + onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; + sessionId?: string | undefined; + setProtocolVersion?: ((version: string) => void) | undefined; + setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; + start(): Promise; +} + +// @public +type TransportSendOptions = { + relatedRequestId?: RequestId | undefined; + resumptionToken?: string | undefined; + onresumptiontoken?: ((token: string) => void) | undefined; +}; + +// @public +export class UnauthorizedClientError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class UnsupportedGrantTypeError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class UnsupportedResponseTypeError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class UnsupportedTokenTypeError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export function allowedMethods(allowedMethods: string[]): RequestHandler; + +// @public (undocumented) +export function authenticateClient(input: ClientAuthenticationMiddlewareOptions): RequestHandler; + +// @public (undocumented) +export function authorizationHandler(input: AuthorizationHandlerOptions): RequestHandler; + +// @public (undocumented) +export function clientRegistrationHandler(input: ClientRegistrationHandlerOptions): RequestHandler; + +// @public (undocumented) +export const createOAuthMetadata: (options: { + provider: OAuthServerProvider; + issuerUrl: URL; + baseUrl?: URL; + serviceDocumentationUrl?: URL; + scopesSupported?: string[]; +}) => OAuthMetadata; + +// @public +export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string; + +// @public (undocumented) +export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router; + +// @public +export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler; + +// @public (undocumented) +export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler; + +// @public +export function redirectUriMatches(requested: string, registered: string): boolean; + +// @public +export function requireBearerAuth(input: BearerAuthMiddlewareOptions): RequestHandler; + +// @public (undocumented) +export function revocationHandler(input: RevocationHandlerOptions): RequestHandler; + +// @public (undocumented) +export function tokenHandler(input: TokenHandlerOptions): RequestHandler; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/server-legacy/etc/server-legacy.auth.api.md b/packages/server-legacy/etc/server-legacy.auth.api.md new file mode 100644 index 0000000000..9b0e11439d --- /dev/null +++ b/packages/server-legacy/etc/server-legacy.auth.api.md @@ -0,0 +1,459 @@ +## API Report File for "@modelcontextprotocol/server-legacy" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import express from 'express'; +import { Options } from 'express-rate-limit'; +import { RequestHandler } from 'express'; +import { Response as Response_2 } from 'express'; +import * as z from 'zod/v4'; + +// @public +export class AccessDeniedError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export interface AuthInfo { + clientId: string; + expiresAt?: number; + extra?: Record; + resource?: URL; + scopes: string[]; + token: string; +} + +// @public (undocumented) +export type AuthMetadataOptions = { + oauthMetadata: OAuthMetadata; + resourceServerUrl: URL; + serviceDocumentationUrl?: URL; + scopesSupported?: string[]; + resourceName?: string; +}; + +// @public (undocumented) +export type AuthRouterOptions = { + provider: OAuthServerProvider; + issuerUrl: URL; + baseUrl?: URL; + serviceDocumentationUrl?: URL; + scopesSupported?: string[]; + resourceName?: string; + resourceServerUrl?: URL; + authorizationOptions?: Omit; + clientRegistrationOptions?: Omit; + revocationOptions?: Omit; + tokenOptions?: Omit; +}; + +// @public (undocumented) +export type AuthorizationHandlerOptions = { + provider: OAuthServerProvider; + rateLimit?: Partial | false; +}; + +// @public (undocumented) +export type AuthorizationParams = { + state?: string; + scopes?: string[]; + codeChallenge: string; + redirectUri: string; + resource?: URL; +}; + +// @public (undocumented) +export type BearerAuthMiddlewareOptions = { + verifier: OAuthTokenVerifier; + requiredScopes?: string[]; + resourceMetadataUrl?: string; +}; + +// @public (undocumented) +export type ClientAuthenticationMiddlewareOptions = { + clientsStore: OAuthRegisteredClientsStore; +}; + +// @public (undocumented) +export type ClientRegistrationHandlerOptions = { + clientsStore: OAuthRegisteredClientsStore; + clientSecretExpirySeconds?: number; + rateLimit?: Partial | false; + clientIdGeneration?: boolean; +}; + +// @public +export class CustomOAuthError extends OAuthError { + constructor(customErrorCode: string, message: string, errorUri?: string); + // (undocumented) + get errorCode(): string; +} + +// @public (undocumented) +type FetchLike = (url: string | URL, init?: RequestInit) => Promise; + +// @public +export class InsufficientScopeError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidClientError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidClientMetadataError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidGrantError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidRequestError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidScopeError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidTargetError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class InvalidTokenError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class MethodNotAllowedError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export const OAUTH_ERRORS: { + readonly [InvalidRequestError.errorCode]: typeof InvalidRequestError; + readonly [InvalidClientError.errorCode]: typeof InvalidClientError; + readonly [InvalidGrantError.errorCode]: typeof InvalidGrantError; + readonly [UnauthorizedClientError.errorCode]: typeof UnauthorizedClientError; + readonly [UnsupportedGrantTypeError.errorCode]: typeof UnsupportedGrantTypeError; + readonly [InvalidScopeError.errorCode]: typeof InvalidScopeError; + readonly [AccessDeniedError.errorCode]: typeof AccessDeniedError; + readonly [ServerError.errorCode]: typeof ServerError; + readonly [TemporarilyUnavailableError.errorCode]: typeof TemporarilyUnavailableError; + readonly [UnsupportedResponseTypeError.errorCode]: typeof UnsupportedResponseTypeError; + readonly [UnsupportedTokenTypeError.errorCode]: typeof UnsupportedTokenTypeError; + readonly [InvalidTokenError.errorCode]: typeof InvalidTokenError; + readonly [MethodNotAllowedError.errorCode]: typeof MethodNotAllowedError; + readonly [TooManyRequestsError.errorCode]: typeof TooManyRequestsError; + readonly [InvalidClientMetadataError.errorCode]: typeof InvalidClientMetadataError; + readonly [InsufficientScopeError.errorCode]: typeof InsufficientScopeError; + readonly [InvalidTargetError.errorCode]: typeof InvalidTargetError; +}; + +// @public (undocumented) +type OAuthClientInformationFull = z.infer; + +// @public +const OAuthClientInformationFullSchema: z.ZodObject<{ + redirect_uris: z.ZodArray; + token_endpoint_auth_method: z.ZodOptional; + grant_types: z.ZodOptional>; + response_types: z.ZodOptional>; + client_name: z.ZodOptional; + client_uri: z.ZodOptional; + logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + scope: z.ZodOptional; + contacts: z.ZodOptional>; + tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + policy_uri: z.ZodOptional; + jwks_uri: z.ZodOptional; + jwks: z.ZodOptional; + software_id: z.ZodOptional; + software_version: z.ZodOptional; + software_statement: z.ZodOptional; + client_id: z.ZodString; + client_secret: z.ZodOptional; + client_id_issued_at: z.ZodOptional; + client_secret_expires_at: z.ZodOptional; +}, z.core.$strip>; + +// @public +export class OAuthError extends Error { + constructor(message: string, errorUri?: string | undefined); + // (undocumented) + static errorCode: string; + // (undocumented) + get errorCode(): string; + // (undocumented) + readonly errorUri?: string | undefined; + toResponseObject(): OAuthErrorResponse; +} + +// @public (undocumented) +type OAuthErrorResponse = z.infer; + +// @public +const OAuthErrorResponseSchema: z.ZodObject<{ + error: z.ZodString; + error_description: z.ZodOptional; + error_uri: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +type OAuthMetadata = z.infer; + +// @public +const OAuthMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + revocation_endpoint: z.ZodOptional; + revocation_endpoint_auth_methods_supported: z.ZodOptional>; + revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + introspection_endpoint: z.ZodOptional; + introspection_endpoint_auth_methods_supported: z.ZodOptional>; + introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + code_challenge_methods_supported: z.ZodOptional>; + client_id_metadata_document_supported: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +type OAuthProtectedResourceMetadata = z.infer; + +// @public +const OAuthProtectedResourceMetadataSchema: z.ZodObject<{ + resource: z.ZodString; + authorization_servers: z.ZodOptional>; + jwks_uri: z.ZodOptional; + scopes_supported: z.ZodOptional>; + bearer_methods_supported: z.ZodOptional>; + resource_signing_alg_values_supported: z.ZodOptional>; + resource_name: z.ZodOptional; + resource_documentation: z.ZodOptional; + resource_policy_uri: z.ZodOptional; + resource_tos_uri: z.ZodOptional; + tls_client_certificate_bound_access_tokens: z.ZodOptional; + authorization_details_types_supported: z.ZodOptional>; + dpop_signing_alg_values_supported: z.ZodOptional>; + dpop_bound_access_tokens_required: z.ZodOptional; +}, z.core.$loose>; + +// @public +export interface OAuthRegisteredClientsStore { + getClient(clientId: string): OAuthClientInformationFull | undefined | Promise; + registerClient?(client: Omit): OAuthClientInformationFull | Promise; +} + +// @public +export interface OAuthServerProvider { + authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response_2): Promise; + challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; + get clientsStore(): OAuthRegisteredClientsStore; + exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, redirectUri?: string, resource?: URL): Promise; + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; + revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; + skipLocalPkceValidation?: boolean; + verifyAccessToken(token: string): Promise; +} + +// @public (undocumented) +type OAuthTokenRevocationRequest = z.infer; + +// @public +const OAuthTokenRevocationRequestSchema: z.ZodObject<{ + token: z.ZodString; + token_type_hint: z.ZodOptional; +}, z.core.$strip>; + +// @public +export interface OAuthTokenVerifier { + verifyAccessToken(token: string): Promise; +} + +// @public (undocumented) +type OAuthTokens = z.infer; + +// @public +const OAuthTokensSchema: z.ZodObject<{ + access_token: z.ZodString; + id_token: z.ZodOptional; + token_type: z.ZodString; + expires_in: z.ZodOptional>; + scope: z.ZodOptional; + refresh_token: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ProxyEndpoints = { + authorizationUrl: string; + tokenUrl: string; + revocationUrl?: string; + registrationUrl?: string; +}; + +// @public +export class ProxyOAuthServerProvider implements OAuthServerProvider { + constructor(options: ProxyOptions); + // (undocumented) + authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response_2): Promise; + // (undocumented) + challengeForAuthorizationCode(_client: OAuthClientInformationFull, _authorizationCode: string): Promise; + // (undocumented) + get clientsStore(): OAuthRegisteredClientsStore; + // (undocumented) + protected readonly _endpoints: ProxyEndpoints; + // (undocumented) + exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, redirectUri?: string, resource?: URL): Promise; + // (undocumented) + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; + // (undocumented) + protected readonly _fetch?: FetchLike; + // (undocumented) + protected readonly _getClient: (clientId: string) => Promise; + // (undocumented) + revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise; + // (undocumented) + skipLocalPkceValidation: boolean; + // (undocumented) + verifyAccessToken(token: string): Promise; + // (undocumented) + protected readonly _verifyAccessToken: (token: string) => Promise; +} + +// @public (undocumented) +export type ProxyOptions = { + endpoints: ProxyEndpoints; + verifyAccessToken: (token: string) => Promise; + getClient: (clientId: string) => Promise; + fetch?: FetchLike; +}; + +// @public (undocumented) +export type RevocationHandlerOptions = { + provider: OAuthServerProvider; + rateLimit?: Partial | false; +}; + +// @public +export class ServerError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class TemporarilyUnavailableError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public (undocumented) +export type TokenHandlerOptions = { + provider: OAuthServerProvider; + rateLimit?: Partial | false; +}; + +// @public +export class TooManyRequestsError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class UnauthorizedClientError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class UnsupportedGrantTypeError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class UnsupportedResponseTypeError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export class UnsupportedTokenTypeError extends OAuthError { + // (undocumented) + static errorCode: string; +} + +// @public +export function allowedMethods(allowedMethods: string[]): RequestHandler; + +// @public (undocumented) +export function authenticateClient(input: ClientAuthenticationMiddlewareOptions): RequestHandler; + +// @public (undocumented) +export function authorizationHandler(input: AuthorizationHandlerOptions): RequestHandler; + +// @public (undocumented) +export function clientRegistrationHandler(input: ClientRegistrationHandlerOptions): RequestHandler; + +// @public (undocumented) +export const createOAuthMetadata: (options: { + provider: OAuthServerProvider; + issuerUrl: URL; + baseUrl?: URL; + serviceDocumentationUrl?: URL; + scopesSupported?: string[]; +}) => OAuthMetadata; + +// @public +export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string; + +// @public (undocumented) +export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router; + +// @public +export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler; + +// @public (undocumented) +export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler; + +// @public +export function redirectUriMatches(requested: string, registered: string): boolean; + +// @public +export function requireBearerAuth(input: BearerAuthMiddlewareOptions): RequestHandler; + +// @public (undocumented) +export function revocationHandler(input: RevocationHandlerOptions): RequestHandler; + +// @public (undocumented) +export function tokenHandler(input: TokenHandlerOptions): RequestHandler; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/server-legacy/etc/server-legacy.sse.api.md b/packages/server-legacy/etc/server-legacy.sse.api.md new file mode 100644 index 0000000000..4273264607 --- /dev/null +++ b/packages/server-legacy/etc/server-legacy.sse.api.md @@ -0,0 +1,149 @@ +## API Report File for "@modelcontextprotocol/server-legacy" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { IncomingMessage } from 'node:http'; +import { ServerResponse } from 'node:http'; +import * as z from 'zod/v4'; + +// @public +interface AuthInfo { + clientId: string; + expiresAt?: number; + extra?: Record; + resource?: URL; + scopes: string[]; + token: string; +} + +// @public (undocumented) +type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; + +// @public (undocumented) +type Infer = Flatten>; + +// @public (undocumented) +type JSONRPCMessage = Infer; + +// @public (undocumented) +const JSONRPCMessageSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; +}, z.core.$strict>, z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>]>; + +// @public +interface MessageExtraInfo { + authInfo?: AuthInfo; + closeSSEStream?: () => void; + closeStandaloneSSEStream?: () => void; + request?: globalThis.Request; +} + +// @public (undocumented) +type Primitive = string | number | boolean | bigint | null | undefined; + +// @public (undocumented) +type RequestId = Infer; + +// @public +const RequestIdSchema: z.ZodUnion; + +// @public @deprecated +export class SSEServerTransport implements Transport { + constructor(_endpoint: string, res: ServerResponse, options?: SSEServerTransportOptions); + // (undocumented) + close(): Promise; + // (undocumented) + handleMessage(message: unknown, extra?: MessageExtraInfo): Promise; + // (undocumented) + handlePostMessage(req: IncomingMessage & { + auth?: AuthInfo; + }, res: ServerResponse, parsedBody?: unknown): Promise; + // (undocumented) + onclose?: () => void; + // (undocumented) + onerror?: ((error: Error) => void) | undefined; + // (undocumented) + onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + // (undocumented) + send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise; + // (undocumented) + get sessionId(): string; + // (undocumented) + start(): Promise; +} + +// @public @deprecated +export interface SSEServerTransportOptions { + // @deprecated (undocumented) + allowedHosts?: string[]; + // @deprecated (undocumented) + allowedOrigins?: string[]; + // @deprecated (undocumented) + enableDnsRebindingProtection?: boolean; +} + +// @public +interface Transport { + close(): Promise; + onclose?: (() => void) | undefined; + onerror?: ((error: Error) => void) | undefined; + onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; + sessionId?: string | undefined; + setProtocolVersion?: ((version: string) => void) | undefined; + setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; + start(): Promise; +} + +// @public +type TransportSendOptions = { + relatedRequestId?: RequestId | undefined; + resumptionToken?: string | undefined; + onresumptiontoken?: ((token: string) => void) | undefined; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/server/etc/server.api.md b/packages/server/etc/server.api.md new file mode 100644 index 0000000000..af06eddac9 --- /dev/null +++ b/packages/server/etc/server.api.md @@ -0,0 +1,8868 @@ +## API Report File for "@modelcontextprotocol/server" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JSONSchema } from 'json-schema-typed'; +import * as z from 'zod/v4'; + +// @public +export class AjvJsonSchemaValidator implements jsonSchemaValidator { + constructor(ajv?: AjvLike); + // (undocumented) + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +interface AjvLike { + // (undocumented) + compile: (schema: unknown) => AjvValidateFunction; + // (undocumented) + errorsText: (errors?: any) => string; + // (undocumented) + getSchema: (keyRef: string) => AjvValidateFunction | undefined; +} + +// @public (undocumented) +interface AjvValidateFunction { + // (undocumented) + (input: unknown): boolean; + // (undocumented) + errors?: any; +} + +// @public (undocumented) +export type Annotations = Infer; + +// @public +const AnnotationsSchema: z.ZodObject<{ + audience: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; +}, z.core.$strip>; + +// @public +export type AnyToolHandler = ToolCallback; + +// @public (undocumented) +export type AudioContent = Infer; + +// @public +const AudioContentSchema: z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public +export interface AuthInfo { + clientId: string; + expiresAt?: number; + extra?: Record; + resource?: URL; + scopes: string[]; + token: string; +} + +// @public (undocumented) +type AuthSchemaKey = keyof typeof authSchemas; + +// @public (undocumented) +export type AuthorizationServerMetadata = OAuthMetadata | OpenIdProviderDiscoveryMetadata; + +// @public +export type BaseContext = { + sessionId?: string; + mcpReq: { + id: RequestId; + method: string; + _meta?: RequestMeta; + signal: AbortSignal; + send: { + (request: { + method: M; + params?: Record; + }, options?: RequestOptions): Promise; + (request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; + }; + notify: (notification: Notification_2) => Promise; + }; + http?: { + authInfo?: AuthInfo; + }; +}; + +// @public (undocumented) +export type BaseMetadata = Infer; + +// @public +const BaseMetadataSchema: z.ZodObject<{ + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public +const BaseRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; +}, z.core.$strip>; + +// @public (undocumented) +export type BaseToolCallback = Args extends StandardSchemaWithJSON ? (args: StandardSchemaWithJSON.InferOutput, ctx: Ctx) => SendResultT | Promise : (ctx: Ctx) => SendResultT | Promise; + +// @public (undocumented) +export type BlobResourceContents = Infer; + +// @public (undocumented) +const BlobResourceContentsSchema: z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; +}, z.core.$strip>; + +// @public (undocumented) +export type BooleanSchema = Infer; + +// @public +const BooleanSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public +export const CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities"; + +// @public +export const CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo"; + +// @public (undocumented) +const COMPLETABLE_SYMBOL: unique symbol; + +// @public (undocumented) +export type CallToolRequest = Infer; + +// @public (undocumented) +export type CallToolRequestParams = Infer; + +// @public +const CallToolRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + name: z.ZodString; + arguments: z.ZodOptional>; +}, z.core.$strip>; + +// @public +const CallToolRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"tools/call">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + name: z.ZodString; + arguments: z.ZodOptional>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type CallToolResult = Infer; + +// @public +const CallToolResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type CancelTaskRequest = Infer; + +// @public +const CancelTaskRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"tasks/cancel">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type CancelTaskResult = Infer; + +// @public +const CancelTaskResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type CancelledNotification = Infer; + +// @public (undocumented) +export type CancelledNotificationParams = Infer; + +// @public (undocumented) +const CancelledNotificationParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + requestId: z.ZodOptional>; + reason: z.ZodOptional; +}, z.core.$strip>; + +// @public +const CancelledNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/cancelled">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + requestId: z.ZodOptional>; + reason: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public +export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { + constructor(options?: { + shortcircuit?: boolean; + draft?: CfWorkerSchemaDraft; + }); + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; + +// @public (undocumented) +export type ClientCapabilities = Infer; + +// @public +const ClientCapabilitiesSchema: z.ZodObject<{ + experimental: z.ZodOptional>>>; + sampling: z.ZodOptional>>; + tools: z.ZodOptional>>; + }, z.core.$strip>>; + elicitation: z.ZodOptional, z.ZodIntersection; + }, z.core.$strip>, z.ZodType>>>; + url: z.ZodOptional>>; + }, z.core.$strip>, z.ZodOptional>>>>>; + roots: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + elicitation: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; +}, z.core.$strip>; + +// @public +export type ClientContext = BaseContext; + +// @public (undocumented) +export type ClientNotification = Infer; + +// @public (undocumented) +const ClientNotificationSchema: z.ZodUnion; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + requestId: z.ZodOptional>; + reason: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/progress">; + params: z.ZodObject<{ + progressToken: z.ZodUnion; + progress: z.ZodNumber; + total: z.ZodOptional; + message: z.ZodOptional; + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/initialized">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/roots/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/tasks/status">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type ClientRequest = Infer; + +// @public (undocumented) +const ClientRequestSchema: z.ZodUnion; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"initialize">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + protocolVersion: z.ZodString; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + sampling: z.ZodOptional>>; + tools: z.ZodOptional>>; + }, z.core.$strip>>; + elicitation: z.ZodOptional, z.ZodIntersection; + }, z.core.$strip>, z.ZodType>>>; + url: z.ZodOptional>>; + }, z.core.$strip>, z.ZodOptional>>>>>; + roots: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + elicitation: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + clientInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"completion/complete">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + ref: z.ZodUnion; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"ref/resource">; + uri: z.ZodString; + }, z.core.$strip>]>; + argument: z.ZodObject<{ + name: z.ZodString; + value: z.ZodString; + }, z.core.$strip>; + context: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"logging/setLevel">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"prompts/get">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + name: z.ZodString; + arguments: z.ZodOptional>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"prompts/list">; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"resources/list">; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"resources/templates/list">; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"resources/read">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"resources/subscribe">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"resources/unsubscribe">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tools/call">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + name: z.ZodString; + arguments: z.ZodOptional>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"tools/list">; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/get">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/result">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"tasks/list">; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/cancel">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type ClientResult = Infer; + +// @public (undocumented) +const ClientResultSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$strict>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + model: z.ZodString; + stopReason: z.ZodOptional, z.ZodString]>>; + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"text">; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + model: z.ZodString; + stopReason: z.ZodOptional, z.ZodString]>>; + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + action: z.ZodEnum<{ + cancel: "cancel"; + accept: "accept"; + decline: "decline"; + }>; + content: z.ZodPipe, z.ZodOptional]>>>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + roots: z.ZodArray; + _meta: z.ZodOptional>; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + tasks: z.ZodArray; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + task: z.ZodObject<{ + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$loose>]>; + +// @public (undocumented) +export type CompatibilityCallToolResult = Infer; + +// @public +const CompatibilityCallToolResultSchema: z.ZodUnion<[z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + toolResult: z.ZodUnknown; +}, z.core.$loose>]>; + +// @public (undocumented) +type CompletableMeta = { + complete: CompleteCallback; +}; + +// @public (undocumented) +export type CompletableSchema = T & { + [COMPLETABLE_SYMBOL]: CompletableMeta; +}; + +// @public (undocumented) +export type CompleteCallback = (value: StandardSchemaV1.InferInput, context?: { + arguments?: Record; +}) => StandardSchemaV1.InferInput[] | Promise[]>; + +// @public (undocumented) +export type CompleteRequest = Infer; + +// @public (undocumented) +export type CompleteRequestParams = Infer; + +// @public +const CompleteRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + ref: z.ZodUnion; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"ref/resource">; + uri: z.ZodString; + }, z.core.$strip>]>; + argument: z.ZodObject<{ + name: z.ZodString; + value: z.ZodString; + }, z.core.$strip>; + context: z.ZodOptional>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type CompleteRequestPrompt = ExpandRecursively; + +// @public (undocumented) +export type CompleteRequestResourceTemplate = ExpandRecursively; + +// @public +const CompleteRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"completion/complete">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + ref: z.ZodUnion; + name: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"ref/resource">; + uri: z.ZodString; + }, z.core.$strip>]>; + argument: z.ZodObject<{ + name: z.ZodString; + value: z.ZodString; + }, z.core.$strip>; + context: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public +export type CompleteResourceTemplateCallback = (value: string, context?: { + arguments?: Record; +}) => string[] | Promise; + +// @public (undocumented) +export type CompleteResult = Infer; + +// @public +const CompleteResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + completion: z.ZodObject<{ + values: z.ZodArray; + total: z.ZodOptional; + hasMore: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$loose>; + +// @public (undocumented) +export type ContentBlock = Infer; + +// @public +const ContentBlockSchema: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type CreateMessageRequest = Infer; + +// @public (undocumented) +export type CreateMessageRequestParams = Infer; + +// @public +export type CreateMessageRequestParamsBase = Omit; + +// @public +const CreateMessageRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + messages: z.ZodArray; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; + _meta: z.ZodOptional>; + }, z.core.$strip>>; + modelPreferences: z.ZodOptional; + }, z.core.$strip>>>; + costPriority: z.ZodOptional; + speedPriority: z.ZodOptional; + intelligencePriority: z.ZodOptional; + }, z.core.$strip>>; + systemPrompt: z.ZodOptional; + includeContext: z.ZodOptional>; + temperature: z.ZodOptional; + maxTokens: z.ZodNumber; + stopSequences: z.ZodOptional>; + metadata: z.ZodOptional>>; + tools: z.ZodOptional; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>>; + toolChoice: z.ZodOptional>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public +export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams { + // (undocumented) + tools: Tool[]; +} + +// @public +const CreateMessageRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"sampling/createMessage">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + messages: z.ZodArray; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; + _meta: z.ZodOptional>; + }, z.core.$strip>>; + modelPreferences: z.ZodOptional; + }, z.core.$strip>>>; + costPriority: z.ZodOptional; + speedPriority: z.ZodOptional; + intelligencePriority: z.ZodOptional; + }, z.core.$strip>>; + systemPrompt: z.ZodOptional; + includeContext: z.ZodOptional>; + temperature: z.ZodOptional; + maxTokens: z.ZodNumber; + stopSequences: z.ZodOptional>; + metadata: z.ZodOptional>>; + tools: z.ZodOptional; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>>; + toolChoice: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type CreateMessageResult = Infer; + +// @public +const CreateMessageResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + model: z.ZodString; + stopReason: z.ZodOptional, z.ZodString]>>; + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"text">; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">; +}, z.core.$loose>; + +// @public (undocumented) +export type CreateMessageResultWithTools = Infer; + +// @public +const CreateMessageResultWithToolsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + model: z.ZodString; + stopReason: z.ZodOptional, z.ZodString]>>; + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; +}, z.core.$loose>; + +// @public (undocumented) +export type CreateTaskResult = Infer; + +// @public +const CreateTaskResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + task: z.ZodObject<{ + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$loose>; + +// @public (undocumented) +export type Cursor = Infer; + +// @public +const CursorSchema: z.ZodString; + +// @public (undocumented) +export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; + +// @public +export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60000; + +// @public (undocumented) +export type DiscoverRequest = Infer; + +// @public +const DiscoverRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"server/discover">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type DiscoverResult = Infer; + +// @public +const DiscoverResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + supportedVersions: z.ZodArray; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + logging: z.ZodOptional>>; + completions: z.ZodOptional>>; + prompts: z.ZodOptional; + }, z.core.$strip>>; + resources: z.ZodOptional; + listChanged: z.ZodOptional; + }, z.core.$strip>>; + tools: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + serverInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + instructions: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type ElicitRequest = Infer; + +// @public (undocumented) +export type ElicitRequestFormParams = Infer; + +// @public +const ElicitRequestFormParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodOptional>; + message: z.ZodString; + requestedSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodRecord; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; + }, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>]>]>, z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>]>>; + required: z.ZodOptional>; + }, z.core.$catchall>; +}, z.core.$strip>; + +// @public (undocumented) +export type ElicitRequestParams = Infer; + +// @public +const ElicitRequestParamsSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodOptional>; + message: z.ZodString; + requestedSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodRecord; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; + }, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>]>]>, z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>]>>; + required: z.ZodOptional>; + }, z.core.$catchall>; +}, z.core.$strip>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodLiteral<"url">; + message: z.ZodString; + elicitationId: z.ZodString; + url: z.ZodString; +}, z.core.$strip>]>; + +// @public +const ElicitRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"elicitation/create">; + params: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodOptional>; + message: z.ZodString; + requestedSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodRecord; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; + }, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>]>]>, z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>]>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + }, z.core.$strip>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodLiteral<"url">; + message: z.ZodString; + elicitationId: z.ZodString; + url: z.ZodString; + }, z.core.$strip>]>; +}, z.core.$strip>; + +// @public (undocumented) +export type ElicitRequestURLParams = Infer; + +// @public +const ElicitRequestURLParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodLiteral<"url">; + message: z.ZodString; + elicitationId: z.ZodString; + url: z.ZodString; +}, z.core.$strip>; + +// @public (undocumented) +export type ElicitResult = Infer; + +// @public +const ElicitResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + action: z.ZodEnum<{ + cancel: "cancel"; + accept: "accept"; + decline: "decline"; + }>; + content: z.ZodPipe, z.ZodOptional]>>>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ElicitationCompleteNotification = Infer; + +// @public (undocumented) +export type ElicitationCompleteNotificationParams = Infer; + +// @public +const ElicitationCompleteNotificationParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + elicitationId: z.ZodString; +}, z.core.$strip>; + +// @public +const ElicitationCompleteNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/elicitation/complete">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + elicitationId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type EmbeddedResource = Infer; + +// @public +const EmbeddedResourceSchema: z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type EmptyResult = Infer; + +// @public +const EmptyResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$strict>; + +// @public (undocumented) +export type EnumSchema = Infer; + +// @public +const EnumSchemaSchema: z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; +}, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>]>]>; + +// @public (undocumented) +export type EventId = string; + +// @public +export interface EventStore { + getStreamIdForEventId?(eventId: EventId): Promise; + // (undocumented) + replayEventsAfter(lastEventId: EventId, input: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise; + }): Promise; + storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; +} + +// @public +type ExpandRecursively = T extends object ? (T extends infer O ? { [K in keyof O]: ExpandRecursively } : never) : T; + +// @public (undocumented) +export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; + +// @public (undocumented) +type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; + +// @public (undocumented) +export type GetPromptRequest = Infer; + +// @public (undocumented) +export type GetPromptRequestParams = Infer; + +// @public +const GetPromptRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + name: z.ZodString; + arguments: z.ZodOptional>; +}, z.core.$strip>; + +// @public +const GetPromptRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"prompts/get">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + name: z.ZodString; + arguments: z.ZodOptional>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type GetPromptResult = Infer; + +// @public +const GetPromptResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + description: z.ZodOptional; + messages: z.ZodArray; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type GetTaskPayloadRequest = Infer; + +// @public +const GetTaskPayloadRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"tasks/result">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type GetTaskPayloadResult = Infer; + +// @public +const GetTaskPayloadResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type GetTaskRequest = Infer; + +// @public +const GetTaskRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"tasks/get">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type GetTaskResult = Infer; + +// @public +const GetTaskResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] }; + +// @public +export interface HandleRequestOptions { + authInfo?: AuthInfo; + parsedBody?: unknown; +} + +// @public (undocumented) +export type HostHeaderValidationResult = { + ok: true; + hostname: string; +} | { + ok: false; + errorCode: 'missing_host' | 'invalid_host_header' | 'invalid_host'; + message: string; + hostHeader?: string; + hostname?: string; +}; + +// @public (undocumented) +export const INTERNAL_ERROR = -32603; + +// @public (undocumented) +export const INVALID_PARAMS = -32602; + +// @public (undocumented) +export const INVALID_REQUEST = -32600; + +// @public (undocumented) +export type Icon = Infer; + +// @public +const IconSchema: z.ZodObject<{ + src: z.ZodString; + mimeType: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type Icons = Infer; + +// @public +const IconsSchema: z.ZodObject<{ + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; +}, z.core.$strip>; + +// @public (undocumented) +export type ImageContent = Infer; + +// @public +const ImageContentSchema: z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type Implementation = Infer; + +// @public +const ImplementationSchema: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public +export class InMemoryTransport implements Transport { + // (undocumented) + close(): Promise; + static createLinkedPair(): [InMemoryTransport, InMemoryTransport]; + // (undocumented) + onclose?: () => void; + // (undocumented) + onerror?: (error: Error) => void; + // (undocumented) + onmessage?: (message: JSONRPCMessage, extra?: { + authInfo?: AuthInfo; + }) => void; + send(message: JSONRPCMessage, options?: { + relatedRequestId?: RequestId; + authInfo?: AuthInfo; + }): Promise; + // (undocumented) + sessionId?: string; + // (undocumented) + start(): Promise; +} + +// @public (undocumented) +type Infer = Flatten>; + +// @public (undocumented) +type InferHandlerResult = R extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : Result; + +// @public +type InferRawShape = z.infer>; + +// @public (undocumented) +export type InitializeRequest = Infer; + +// @public (undocumented) +export type InitializeRequestParams = Infer; + +// @public (undocumented) +const InitializeRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + protocolVersion: z.ZodString; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + sampling: z.ZodOptional>>; + tools: z.ZodOptional>>; + }, z.core.$strip>>; + elicitation: z.ZodOptional, z.ZodIntersection; + }, z.core.$strip>, z.ZodType>>>; + url: z.ZodOptional>>; + }, z.core.$strip>, z.ZodOptional>>>>>; + roots: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + elicitation: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + clientInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public +const InitializeRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"initialize">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + protocolVersion: z.ZodString; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + sampling: z.ZodOptional>>; + tools: z.ZodOptional>>; + }, z.core.$strip>>; + elicitation: z.ZodOptional, z.ZodIntersection; + }, z.core.$strip>, z.ZodType>>>; + url: z.ZodOptional>>; + }, z.core.$strip>, z.ZodOptional>>>>>; + roots: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + elicitation: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + clientInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type InitializeResult = Infer; + +// @public +const InitializeResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + protocolVersion: z.ZodString; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + logging: z.ZodOptional>>; + completions: z.ZodOptional>>; + prompts: z.ZodOptional; + }, z.core.$strip>>; + resources: z.ZodOptional; + listChanged: z.ZodOptional; + }, z.core.$strip>>; + tools: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + serverInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + instructions: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type InitializedNotification = Infer; + +// @public +const InitializedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/initialized">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export interface InternalError extends JSONRPCErrorObject { + // (undocumented) + code: typeof INTERNAL_ERROR; +} + +// @public (undocumented) +export interface InvalidParamsError extends JSONRPCErrorObject { + // (undocumented) + code: typeof INVALID_PARAMS; +} + +// @public (undocumented) +export interface InvalidRequestError extends JSONRPCErrorObject { + // (undocumented) + code: typeof INVALID_REQUEST; +} + +// @public (undocumented) +export type JSONArray = JSONValue[]; + +// @public (undocumented) +export type JSONObject = { + [key: string]: JSONValue; +}; + +// @public (undocumented) +type JSONRPCErrorObject = { + code: number; + message: string; + data?: unknown; +}; + +// @public (undocumented) +export type JSONRPCErrorResponse = Infer; + +// @public +const JSONRPCErrorResponseSchema: z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>; + +// @public (undocumented) +export type JSONRPCMessage = Infer; + +// @public (undocumented) +const JSONRPCMessageSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; +}, z.core.$strict>, z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>]>; + +// @public (undocumented) +export type JSONRPCNotification = Infer; + +// @public +const JSONRPCNotificationSchema: z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; +}, z.core.$strict>; + +// @public (undocumented) +export type JSONRPCRequest = Infer; + +// @public +const JSONRPCRequestSchema: z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; +}, z.core.$strict>; + +// @public (undocumented) +export type JSONRPCResponse = Infer; + +// @public (undocumented) +const JSONRPCResponseSchema: z.ZodUnion; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>]>; + +// @public (undocumented) +export type JSONRPCResultResponse = Infer; + +// @public +const JSONRPCResultResponseSchema: z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>; + +// @public (undocumented) +export const JSONRPC_VERSION = "2.0"; + +// @public (undocumented) +export type JSONValue = string | number | boolean | null | JSONObject | JSONArray; + +// @public +export type JsonSchemaType = JSONSchema.Interface; + +// @public +export type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +export type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public (undocumented) +export const LATEST_PROTOCOL_VERSION = "2025-11-25"; + +// @public @deprecated +export const LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel"; + +// @public +type LegacyPromptCallback = Args extends ZodRawShape ? (args: InferRawShape, ctx: ServerContext) => GetPromptResult | Promise : (ctx: ServerContext) => GetPromptResult | Promise; + +// @public (undocumented) +export type LegacyTitledEnumSchema = Infer; + +// @public +const LegacyTitledEnumSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public +type LegacyToolCallback = Args extends ZodRawShape ? (args: InferRawShape, ctx: ServerContext) => CallToolResult | Promise : (ctx: ServerContext) => CallToolResult | Promise; + +// @public +export type ListChangedCallback = (error: Error | null, items: T[] | null) => void; + +// @public +export type ListChangedHandlers = { + tools?: ListChangedOptions; + prompts?: ListChangedOptions; + resources?: ListChangedOptions; +}; + +// @public +export type ListChangedOptions = { + autoRefresh?: boolean; + debounceMs?: number; + onChanged: ListChangedCallback; +}; + +// @public (undocumented) +export type ListPromptsRequest = Infer; + +// @public +const ListPromptsRequestSchema: z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"prompts/list">; +}, z.core.$strip>; + +// @public (undocumented) +export type ListPromptsResult = Infer; + +// @public +const ListPromptsResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + prompts: z.ZodArray; + arguments: z.ZodOptional; + required: z.ZodOptional; + }, z.core.$strip>>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ListResourceTemplatesRequest = Infer; + +// @public +const ListResourceTemplatesRequestSchema: z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"resources/templates/list">; +}, z.core.$strip>; + +// @public (undocumented) +export type ListResourceTemplatesResult = Infer; + +// @public +const ListResourceTemplatesResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + resourceTemplates: z.ZodArray; + mimeType: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public +export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult | Promise; + +// @public (undocumented) +export type ListResourcesRequest = Infer; + +// @public +const ListResourcesRequestSchema: z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"resources/list">; +}, z.core.$strip>; + +// @public (undocumented) +export type ListResourcesResult = Infer; + +// @public +const ListResourcesResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + resources: z.ZodArray; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ListRootsRequest = Infer; + +// @public +const ListRootsRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"roots/list">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type ListRootsResult = Infer; + +// @public +const ListRootsResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + roots: z.ZodArray; + _meta: z.ZodOptional>; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ListTasksRequest = Infer; + +// @public +const ListTasksRequestSchema: z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"tasks/list">; +}, z.core.$strip>; + +// @public (undocumented) +export type ListTasksResult = Infer; + +// @public +const ListTasksResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + tasks: z.ZodArray; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type ListToolsRequest = Infer; + +// @public +const ListToolsRequestSchema: z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"tools/list">; +}, z.core.$strip>; + +// @public (undocumented) +export type ListToolsResult = Infer; + +// @public +const ListToolsResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + tools: z.ZodArray; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>; + +// @public (undocumented) +export type LoggingLevel = Infer; + +// @public +const LoggingLevelSchema: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; +}>; + +// @public (undocumented) +export type LoggingMessageNotification = Infer; + +// @public (undocumented) +export type LoggingMessageNotificationParams = Infer; + +// @public +const LoggingMessageNotificationParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; + logger: z.ZodOptional; + data: z.ZodUnknown; +}, z.core.$strip>; + +// @public +const LoggingMessageNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/message">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; + logger: z.ZodOptional; + data: z.ZodUnknown; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export const METHOD_NOT_FOUND = -32601; + +// @public +export class McpServer { + constructor(serverInfo: Implementation, options?: ServerOptions); + close(): Promise; + connect(transport: Transport): Promise; + isConnected(): boolean; + registerPrompt(name: string, config: { + title?: string; + description?: string; + argsSchema?: Args; + _meta?: Record; + }, cb: PromptCallback): RegisteredPrompt; + // @deprecated (undocumented) + registerPrompt(name: string, config: { + title?: string; + description?: string; + argsSchema?: Args; + _meta?: Record; + }, cb: LegacyPromptCallback): RegisteredPrompt; + registerResource(name: string, uriOrTemplate: string, config: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; + // (undocumented) + registerResource(name: string, uriOrTemplate: ResourceTemplate, config: ResourceMetadata, readCallback: ReadResourceTemplateCallback): RegisteredResourceTemplate; + registerTool(name: string, config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + _meta?: Record; + }, cb: ToolCallback): RegisteredTool; + // @deprecated (undocumented) + registerTool(name: string, config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + _meta?: Record; + }, cb: LegacyToolCallback): RegisteredTool; + sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string): Promise; + sendPromptListChanged(): void; + sendResourceListChanged(): void; + sendToolListChanged(): void; + readonly server: Server; +} + +// @public +export interface MessageExtraInfo { + authInfo?: AuthInfo; + closeSSEStream?: () => void; + closeStandaloneSSEStream?: () => void; + request?: globalThis.Request; +} + +// @public (undocumented) +export type MetaObject = Record; + +// @public (undocumented) +export interface MethodNotFoundError extends JSONRPCErrorObject { + // (undocumented) + code: typeof METHOD_NOT_FOUND; +} + +// @public (undocumented) +type MethodToTypeMap = { [T in U as T extends { + method: infer M extends string; + } ? M : never]: T }; + +// @public (undocumented) +export type ModelHint = Infer; + +// @public +const ModelHintSchema: z.ZodObject<{ + name: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ModelPreferences = Infer; + +// @public +const ModelPreferencesSchema: z.ZodObject<{ + hints: z.ZodOptional; + }, z.core.$strip>>>; + costPriority: z.ZodOptional; + speedPriority: z.ZodOptional; + intelligencePriority: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type MultiSelectEnumSchema = Infer; + +// @public +const MultiSelectEnumSchemaSchema: z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type NotificationMethod = ClientNotification['method'] | ServerNotification['method']; + +// @public +type NotificationOptions_2 = { + relatedRequestId?: RequestId; +}; +export { NotificationOptions_2 as NotificationOptions } + +// @public (undocumented) +export type NotificationParams = Infer; + +// @public (undocumented) +const NotificationSchema: z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; +}, z.core.$strip>; + +// @public (undocumented) +export type NotificationTypeMap = MethodToTypeMap; + +// @public (undocumented) +type Notification_2 = Infer; +export { Notification_2 as Notification } + +// @public (undocumented) +const NotificationsParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; +}, z.core.$strip>; + +// @public (undocumented) +export type NumberSchema = Infer; + +// @public +const NumberSchemaSchema: z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthClientInformation = z.infer; + +// @public (undocumented) +export type OAuthClientInformationFull = z.infer; + +// @public +const OAuthClientInformationFullSchema: z.ZodObject<{ + redirect_uris: z.ZodArray; + token_endpoint_auth_method: z.ZodOptional; + grant_types: z.ZodOptional>; + response_types: z.ZodOptional>; + client_name: z.ZodOptional; + client_uri: z.ZodOptional; + logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + scope: z.ZodOptional; + contacts: z.ZodOptional>; + tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + policy_uri: z.ZodOptional; + jwks_uri: z.ZodOptional; + jwks: z.ZodOptional; + software_id: z.ZodOptional; + software_version: z.ZodOptional; + software_statement: z.ZodOptional; + client_id: z.ZodString; + client_secret: z.ZodOptional; + client_id_issued_at: z.ZodOptional; + client_secret_expires_at: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthClientInformationMixed = OAuthClientInformation | OAuthClientInformationFull; + +// @public +const OAuthClientInformationSchema: z.ZodObject<{ + client_id: z.ZodString; + client_secret: z.ZodOptional; + client_id_issued_at: z.ZodOptional; + client_secret_expires_at: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthClientMetadata = z.infer; + +// @public +const OAuthClientMetadataSchema: z.ZodObject<{ + redirect_uris: z.ZodArray; + token_endpoint_auth_method: z.ZodOptional; + grant_types: z.ZodOptional>; + response_types: z.ZodOptional>; + client_name: z.ZodOptional; + client_uri: z.ZodOptional; + logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + scope: z.ZodOptional; + contacts: z.ZodOptional>; + tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + policy_uri: z.ZodOptional; + jwks_uri: z.ZodOptional; + jwks: z.ZodOptional; + software_id: z.ZodOptional; + software_version: z.ZodOptional; + software_statement: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthClientRegistrationError = z.infer; + +// @public +const OAuthClientRegistrationErrorSchema: z.ZodObject<{ + error: z.ZodString; + error_description: z.ZodOptional; +}, z.core.$strip>; + +// @public +export class OAuthError extends Error { + constructor(code: OAuthErrorCode | string, message: string, errorUri?: string | undefined); + // (undocumented) + readonly code: OAuthErrorCode | string; + // (undocumented) + readonly errorUri?: string | undefined; + static fromResponse(response: OAuthErrorResponse): OAuthError; + toResponseObject(): OAuthErrorResponse; +} + +// @public +export enum OAuthErrorCode { + AccessDenied = "access_denied", + InsufficientScope = "insufficient_scope", + InvalidClient = "invalid_client", + InvalidClientMetadata = "invalid_client_metadata", + InvalidGrant = "invalid_grant", + InvalidRequest = "invalid_request", + InvalidScope = "invalid_scope", + InvalidTarget = "invalid_target", + InvalidToken = "invalid_token", + MethodNotAllowed = "method_not_allowed", + ServerError = "server_error", + TemporarilyUnavailable = "temporarily_unavailable", + TooManyRequests = "too_many_requests", + UnauthorizedClient = "unauthorized_client", + UnsupportedGrantType = "unsupported_grant_type", + UnsupportedResponseType = "unsupported_response_type", + UnsupportedTokenType = "unsupported_token_type", +} + +// @public (undocumented) +export type OAuthErrorResponse = z.infer; + +// @public +const OAuthErrorResponseSchema: z.ZodObject<{ + error: z.ZodString; + error_description: z.ZodOptional; + error_uri: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthMetadata = z.infer; + +// @public +const OAuthMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + revocation_endpoint: z.ZodOptional; + revocation_endpoint_auth_methods_supported: z.ZodOptional>; + revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + introspection_endpoint: z.ZodOptional; + introspection_endpoint_auth_methods_supported: z.ZodOptional>; + introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + code_challenge_methods_supported: z.ZodOptional>; + client_id_metadata_document_supported: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type OAuthProtectedResourceMetadata = z.infer; + +// @public +const OAuthProtectedResourceMetadataSchema: z.ZodObject<{ + resource: z.ZodString; + authorization_servers: z.ZodOptional>; + jwks_uri: z.ZodOptional; + scopes_supported: z.ZodOptional>; + bearer_methods_supported: z.ZodOptional>; + resource_signing_alg_values_supported: z.ZodOptional>; + resource_name: z.ZodOptional; + resource_documentation: z.ZodOptional; + resource_policy_uri: z.ZodOptional; + resource_tos_uri: z.ZodOptional; + tls_client_certificate_bound_access_tokens: z.ZodOptional; + authorization_details_types_supported: z.ZodOptional>; + dpop_signing_alg_values_supported: z.ZodOptional>; + dpop_bound_access_tokens_required: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type OAuthTokenRevocationRequest = z.infer; + +// @public +const OAuthTokenRevocationRequestSchema: z.ZodObject<{ + token: z.ZodString; + token_type_hint: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OAuthTokens = z.infer; + +// @public +const OAuthTokensSchema: z.ZodObject<{ + access_token: z.ZodString; + id_token: z.ZodOptional; + token_type: z.ZodString; + expires_in: z.ZodOptional>; + scope: z.ZodOptional; + refresh_token: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OpenIdProviderDiscoveryMetadata = z.infer; + +// @public +const OpenIdProviderDiscoveryMetadataSchema: z.ZodObject<{ + code_challenge_methods_supported: z.ZodOptional>; + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + userinfo_endpoint: z.ZodOptional; + jwks_uri: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + acr_values_supported: z.ZodOptional>; + subject_types_supported: z.ZodArray; + id_token_signing_alg_values_supported: z.ZodArray; + id_token_encryption_alg_values_supported: z.ZodOptional>; + id_token_encryption_enc_values_supported: z.ZodOptional>; + userinfo_signing_alg_values_supported: z.ZodOptional>; + userinfo_encryption_alg_values_supported: z.ZodOptional>; + userinfo_encryption_enc_values_supported: z.ZodOptional>; + request_object_signing_alg_values_supported: z.ZodOptional>; + request_object_encryption_alg_values_supported: z.ZodOptional>; + request_object_encryption_enc_values_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + display_values_supported: z.ZodOptional>; + claim_types_supported: z.ZodOptional>; + claims_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + claims_locales_supported: z.ZodOptional>; + ui_locales_supported: z.ZodOptional>; + claims_parameter_supported: z.ZodOptional; + request_parameter_supported: z.ZodOptional; + request_uri_parameter_supported: z.ZodOptional; + require_request_uri_registration: z.ZodOptional; + op_policy_uri: z.ZodOptional; + op_tos_uri: z.ZodOptional; + client_id_metadata_document_supported: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type OpenIdProviderMetadata = z.infer; + +// @public +const OpenIdProviderMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + userinfo_endpoint: z.ZodOptional; + jwks_uri: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + acr_values_supported: z.ZodOptional>; + subject_types_supported: z.ZodArray; + id_token_signing_alg_values_supported: z.ZodArray; + id_token_encryption_alg_values_supported: z.ZodOptional>; + id_token_encryption_enc_values_supported: z.ZodOptional>; + userinfo_signing_alg_values_supported: z.ZodOptional>; + userinfo_encryption_alg_values_supported: z.ZodOptional>; + userinfo_encryption_enc_values_supported: z.ZodOptional>; + request_object_signing_alg_values_supported: z.ZodOptional>; + request_object_encryption_alg_values_supported: z.ZodOptional>; + request_object_encryption_enc_values_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + display_values_supported: z.ZodOptional>; + claim_types_supported: z.ZodOptional>; + claims_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + claims_locales_supported: z.ZodOptional>; + ui_locales_supported: z.ZodOptional>; + claims_parameter_supported: z.ZodOptional; + request_parameter_supported: z.ZodOptional; + request_uri_parameter_supported: z.ZodOptional; + require_request_uri_registration: z.ZodOptional; + op_policy_uri: z.ZodOptional; + op_tos_uri: z.ZodOptional; + client_id_metadata_document_supported: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export const PARSE_ERROR = -32700; + +// @public +export const PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"; + +// @public (undocumented) +export type PaginatedRequest = Infer; + +// @public (undocumented) +export type PaginatedRequestParams = Infer; + +// @public (undocumented) +const PaginatedRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +const PaginatedRequestSchema: z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type PaginatedResult = Infer; + +// @public (undocumented) +const PaginatedResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export interface ParseError extends JSONRPCErrorObject { + // (undocumented) + code: typeof PARSE_ERROR; +} + +// @public (undocumented) +export type PingRequest = Infer; + +// @public +const PingRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"ping">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +type Primitive = string | number | boolean | bigint | null | undefined; + +// @public (undocumented) +export type PrimitiveSchemaDefinition = Infer; + +// @public +const PrimitiveSchemaDefinitionSchema: z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; +}, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>]>]>, z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; +}, z.core.$strip>]>; + +// @public (undocumented) +export type Progress = Infer; + +// @public +export type ProgressCallback = (progress: Progress) => void; + +// @public (undocumented) +export type ProgressNotification = Infer; + +// @public (undocumented) +export type ProgressNotificationParams = Infer; + +// @public (undocumented) +const ProgressNotificationParamsSchema: z.ZodObject<{ + progressToken: z.ZodUnion; + progress: z.ZodNumber; + total: z.ZodOptional; + message: z.ZodOptional; + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; +}, z.core.$strip>; + +// @public +const ProgressNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/progress">; + params: z.ZodObject<{ + progressToken: z.ZodUnion; + progress: z.ZodNumber; + total: z.ZodOptional; + message: z.ZodOptional; + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +const ProgressSchema: z.ZodObject<{ + progress: z.ZodNumber; + total: z.ZodOptional; + message: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ProgressToken = Infer; + +// @public +const ProgressTokenSchema: z.ZodUnion; + +// @public (undocumented) +export type Prompt = Infer; + +// @public (undocumented) +export type PromptArgument = Infer; + +// @public +const PromptArgumentSchema: z.ZodObject<{ + name: z.ZodString; + description: z.ZodOptional; + required: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type PromptCallback = Args extends StandardSchemaWithJSON ? (args: StandardSchemaWithJSON.InferOutput, ctx: ServerContext) => GetPromptResult | Promise : (ctx: ServerContext) => GetPromptResult | Promise; + +// @public +type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; + +// @public (undocumented) +export type PromptListChangedNotification = Infer; + +// @public +const PromptListChangedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/prompts/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type PromptMessage = Infer; + +// @public +const PromptMessageSchema: z.ZodObject<{ + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>; +}, z.core.$strip>; + +// @public (undocumented) +export type PromptReference = Infer; + +// @public +const PromptReferenceSchema: z.ZodObject<{ + type: z.ZodLiteral<"ref/prompt">; + name: z.ZodString; +}, z.core.$strip>; + +// @public +const PromptSchema: z.ZodObject<{ + description: z.ZodOptional; + arguments: z.ZodOptional; + required: z.ZodOptional; + }, z.core.$strip>>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public +abstract class Protocol { + constructor(_options?: ProtocolOptions | undefined); + assertCanSetRequestHandler(method: RequestMethod | string): void; + protected abstract assertCapabilityForMethod(method: RequestMethod | string): void; + protected abstract assertNotificationCapability(method: NotificationMethod | string): void; + protected abstract assertRequestHandlerCapability(method: string): void; + protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; + close(): Promise; + connect(transport: Transport): Promise; + fallbackNotificationHandler?: (notification: Notification_2) => Promise; + fallbackRequestHandler?: (request: JSONRPCRequest, ctx: ContextT) => Promise; + notification(notification: Notification_2, options?: NotificationOptions_2): Promise; + onclose?: () => void; + onerror?: (error: Error) => void; + removeNotificationHandler(method: NotificationMethod | string): void; + removeRequestHandler(method: RequestMethod | string): void; + request(request: { + method: M; + params?: Record; + }, options?: RequestOptions): Promise; + // (undocumented) + request(request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; + protected _requestWithSchema(request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; + setNotificationHandler(method: M, handler: (notification: NotificationTypeMap[M]) => void | Promise): void; + // (undocumented) + setNotificationHandler

(method: string, schemas: { + params: P; + }, handler: (params: StandardSchemaV1.InferOutput

, notification: Notification_2) => void | Promise): void; + setRequestHandler(method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => ResultTypeMap[M] | Promise): void; + // (undocumented) + setRequestHandler

(method: string, schemas: { + params: P; + result?: R; + }, handler: (params: StandardSchemaV1.InferOutput

, ctx: ContextT) => InferHandlerResult | Promise>): void; + // (undocumented) + protected _supportedProtocolVersions: string[]; + // (undocumented) + get transport(): Transport | undefined; + protected _wrapHandler(_method: string, handler: (request: JSONRPCRequest, ctx: ContextT) => Promise): (request: JSONRPCRequest, ctx: ContextT) => Promise; +} + +// @public +export class ProtocolError extends Error { + constructor(code: number, message: string, data?: unknown | undefined); + // (undocumented) + readonly code: number; + // (undocumented) + readonly data?: unknown | undefined; + static fromError(code: number, message: string, data?: unknown): ProtocolError; +} + +// @public +export enum ProtocolErrorCode { + // (undocumented) + InternalError = -32603, + // (undocumented) + InvalidParams = -32602, + // (undocumented) + InvalidRequest = -32600, + // (undocumented) + MethodNotFound = -32601, + MissingRequiredClientCapability = -32003, + // (undocumented) + ParseError = -32700, + // (undocumented) + ResourceNotFound = -32002, + UnsupportedProtocolVersion = -32004, + // (undocumented) + UrlElicitationRequired = -32042, +} + +// @public +export type ProtocolOptions = { + supportedProtocolVersions?: string[]; + enforceStrictCapabilities?: boolean; + debouncedNotificationMethods?: string[]; +}; + +// @public (undocumented) +type ProtocolSchemaKey = (typeof SPEC_SCHEMA_KEYS)[number]; + +// @public (undocumented) +export const RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; + +// @public +export class ReadBuffer { + constructor(options?: { + maxBufferSize?: number; + }); + // (undocumented) + append(chunk: Buffer): void; + // (undocumented) + clear(): void; + // (undocumented) + readMessage(): JSONRPCMessage | null; +} + +// @public +export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; + +// @public (undocumented) +export type ReadResourceRequest = Infer; + +// @public (undocumented) +export type ReadResourceRequestParams = Infer; + +// @public +const ReadResourceRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const ReadResourceRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"resources/read">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type ReadResourceResult = Infer; + +// @public +const ReadResourceResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + contents: z.ZodArray; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>>; +}, z.core.$loose>; + +// @public +export type ReadResourceTemplateCallback = (uri: URL, variables: Variables, ctx: ServerContext) => ReadResourceResult | Promise; + +// @public (undocumented) +export type RegisteredPrompt = { + title?: string; + description?: string; + argsSchema?: StandardSchemaWithJSON; + _meta?: Record; + handler: PromptHandler; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + argsSchema?: Args; + _meta?: Record; + callback?: PromptCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +// @public (undocumented) +export type RegisteredResource = { + name: string; + title?: string; + metadata?: ResourceMetadata; + readCallback: ReadResourceCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string; + title?: string; + uri?: string | null; + metadata?: ResourceMetadata; + callback?: ReadResourceCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +// @public (undocumented) +export type RegisteredResourceTemplate = { + resourceTemplate: ResourceTemplate; + title?: string; + metadata?: ResourceMetadata; + readCallback: ReadResourceTemplateCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + template?: ResourceTemplate; + metadata?: ResourceMetadata; + callback?: ReadResourceTemplateCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +// @public (undocumented) +export type RegisteredTool = { + title?: string; + description?: string; + inputSchema?: StandardSchemaWithJSON; + outputSchema?: StandardSchemaWithJSON; + annotations?: ToolAnnotations; + execution?: ToolExecution; + _meta?: Record; + handler: AnyToolHandler; + executor: ToolExecutor; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + paramsSchema?: StandardSchemaWithJSON; + outputSchema?: StandardSchemaWithJSON; + annotations?: ToolAnnotations; + _meta?: Record; + callback?: ToolCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +// @public (undocumented) +export type RelatedTaskMetadata = Infer; + +// @public +const RelatedTaskMetadataSchema: z.ZodObject<{ + taskId: z.ZodString; +}, z.core.$strip>; + +// @public +export interface RequestHandlerSchemas

{ + // (undocumented) + params: P; + // (undocumented) + result?: R; +} + +// @public (undocumented) +export type RequestId = Infer; + +// @public +const RequestIdSchema: z.ZodUnion; + +// @public (undocumented) +export type RequestMeta = Infer; + +// @public +export type RequestMetaEnvelope = Infer; + +// @public +const RequestMetaEnvelopeSchema: z.ZodObject<{ + progressToken: z.ZodOptional>; + "io.modelcontextprotocol/protocolVersion": z.ZodString; + "io.modelcontextprotocol/clientInfo": z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + "io.modelcontextprotocol/clientCapabilities": z.ZodObject<{ + experimental: z.ZodOptional>>>; + sampling: z.ZodOptional>>; + tools: z.ZodOptional>>; + }, z.core.$strip>>; + elicitation: z.ZodOptional, z.ZodIntersection; + }, z.core.$strip>, z.ZodType>>>; + url: z.ZodOptional>>; + }, z.core.$strip>, z.ZodOptional>>>>>; + roots: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + elicitation: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + "io.modelcontextprotocol/logLevel": z.ZodOptional>; +}, z.core.$loose>; + +// @public (undocumented) +export type RequestMetaObject = RequestMeta; + +// @public (undocumented) +const RequestMetaSchema: z.ZodObject<{ + progressToken: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; +}, z.core.$loose>; + +// @public (undocumented) +export type RequestMethod = ClientRequest['method'] | ServerRequest['method']; + +// @public +export type RequestOptions = { + onprogress?: ProgressCallback; + signal?: AbortSignal; + timeout?: number; + resetTimeoutOnProgress?: boolean; + maxTotalTimeout?: number; +} & TransportSendOptions; + +// @public (undocumented) +export type RequestParams = Infer; + +// @public (undocumented) +const RequestSchema: z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; +}, z.core.$strip>; + +// @public (undocumented) +export type RequestTypeMap = MethodToTypeMap; + +// @public (undocumented) +type Request_2 = Infer; +export { Request_2 as Request } + +// @public (undocumented) +export type Resource = Infer; + +// @public (undocumented) +export type ResourceContents = Infer; + +// @public +const ResourceContentsSchema: z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type ResourceLink = Infer; + +// @public +const ResourceLinkSchema: z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; +}, z.core.$strip>; + +// @public (undocumented) +export type ResourceListChangedNotification = Infer; + +// @public +const ResourceListChangedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/resources/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public +export type ResourceMetadata = Omit; + +// @public (undocumented) +export type ResourceRequestParams = Infer; + +// @public (undocumented) +const ResourceRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const ResourceSchema: z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public +export class ResourceTemplate { + constructor(uriTemplate: string | UriTemplate, _callbacks: { + list: ListResourcesCallback | undefined; + complete?: { + [variable: string]: CompleteResourceTemplateCallback; + }; + }); + completeCallback(variable: string): CompleteResourceTemplateCallback | undefined; + get listCallback(): ListResourcesCallback | undefined; + get uriTemplate(): UriTemplate; +} + +// @public (undocumented) +export type ResourceTemplateReference = Infer; + +// @public +const ResourceTemplateReferenceSchema: z.ZodObject<{ + type: z.ZodLiteral<"ref/resource">; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const ResourceTemplateSchema: z.ZodObject<{ + uriTemplate: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ResourceTemplateType = Infer; + +// @public (undocumented) +export type ResourceUpdatedNotification = Infer; + +// @public (undocumented) +export type ResourceUpdatedNotificationParams = Infer; + +// @public +const ResourceUpdatedNotificationParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const ResourceUpdatedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/resources/updated">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type Result = Infer; + +// @public (undocumented) +const ResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type ResultTypeMap = { + ping: EmptyResult; + initialize: InitializeResult; + 'completion/complete': CompleteResult; + 'logging/setLevel': EmptyResult; + 'prompts/get': GetPromptResult; + 'prompts/list': ListPromptsResult; + 'resources/list': ListResourcesResult; + 'resources/templates/list': ListResourceTemplatesResult; + 'resources/read': ReadResourceResult; + 'resources/subscribe': EmptyResult; + 'resources/unsubscribe': EmptyResult; + 'tools/call': CallToolResult | CreateTaskResult; + 'tools/list': ListToolsResult; + 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools | CreateTaskResult; + 'elicitation/create': ElicitResult | CreateTaskResult; + 'roots/list': ListRootsResult; + 'tasks/get': GetTaskResult; + 'tasks/result': Result; + 'tasks/list': ListTasksResult; + 'tasks/cancel': CancelTaskResult; +}; + +// @public (undocumented) +export type Role = Infer; + +// @public +const RoleSchema: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; +}>; + +// @public (undocumented) +export type Root = Infer; + +// @public +const RootSchema: z.ZodObject<{ + uri: z.ZodString; + name: z.ZodOptional; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type RootsListChangedNotification = Infer; + +// @public +const RootsListChangedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/roots/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public +const SPEC_SCHEMA_KEYS: readonly ["AnnotationsSchema", "AudioContentSchema", "BaseMetadataSchema", "BlobResourceContentsSchema", "BooleanSchemaSchema", "CallToolRequestSchema", "CallToolRequestParamsSchema", "CallToolResultSchema", "CancelledNotificationSchema", "CancelledNotificationParamsSchema", "CancelTaskRequestSchema", "CancelTaskResultSchema", "ClientCapabilitiesSchema", "ClientNotificationSchema", "ClientRequestSchema", "ClientResultSchema", "CompatibilityCallToolResultSchema", "CompleteRequestSchema", "CompleteRequestParamsSchema", "CompleteResultSchema", "ContentBlockSchema", "CreateMessageRequestSchema", "CreateMessageRequestParamsSchema", "CreateMessageResultSchema", "CreateMessageResultWithToolsSchema", "CreateTaskResultSchema", "CursorSchema", "DiscoverRequestSchema", "DiscoverResultSchema", "ElicitationCompleteNotificationSchema", "ElicitationCompleteNotificationParamsSchema", "ElicitRequestSchema", "ElicitRequestFormParamsSchema", "ElicitRequestParamsSchema", "ElicitRequestURLParamsSchema", "ElicitResultSchema", "EmbeddedResourceSchema", "EmptyResultSchema", "EnumSchemaSchema", "GetPromptRequestSchema", "GetPromptRequestParamsSchema", "GetPromptResultSchema", "GetTaskPayloadRequestSchema", "GetTaskPayloadResultSchema", "GetTaskRequestSchema", "GetTaskResultSchema", "IconSchema", "IconsSchema", "ImageContentSchema", "ImplementationSchema", "InitializedNotificationSchema", "InitializeRequestSchema", "InitializeRequestParamsSchema", "InitializeResultSchema", "JSONArraySchema", "JSONObjectSchema", "JSONRPCErrorResponseSchema", "JSONRPCMessageSchema", "JSONRPCNotificationSchema", "JSONRPCRequestSchema", "JSONRPCResponseSchema", "JSONRPCResultResponseSchema", "JSONValueSchema", "LegacyTitledEnumSchemaSchema", "ListPromptsRequestSchema", "ListPromptsResultSchema", "ListResourcesRequestSchema", "ListResourcesResultSchema", "ListResourceTemplatesRequestSchema", "ListResourceTemplatesResultSchema", "ListRootsRequestSchema", "ListRootsResultSchema", "ListTasksRequestSchema", "ListTasksResultSchema", "ListToolsRequestSchema", "ListToolsResultSchema", "LoggingLevelSchema", "LoggingMessageNotificationSchema", "LoggingMessageNotificationParamsSchema", "ModelHintSchema", "ModelPreferencesSchema", "MultiSelectEnumSchemaSchema", "NotificationSchema", "NumberSchemaSchema", "PaginatedRequestSchema", "PaginatedRequestParamsSchema", "PaginatedResultSchema", "PingRequestSchema", "PrimitiveSchemaDefinitionSchema", "ProgressSchema", "ProgressNotificationSchema", "ProgressNotificationParamsSchema", "ProgressTokenSchema", "PromptSchema", "PromptArgumentSchema", "PromptListChangedNotificationSchema", "PromptMessageSchema", "PromptReferenceSchema", "ReadResourceRequestSchema", "ReadResourceRequestParamsSchema", "ReadResourceResultSchema", "RelatedTaskMetadataSchema", "RequestSchema", "RequestIdSchema", "RequestMetaEnvelopeSchema", "RequestMetaSchema", "ResourceSchema", "ResourceContentsSchema", "ResourceLinkSchema", "ResourceListChangedNotificationSchema", "ResourceRequestParamsSchema", "ResourceTemplateSchema", "ResourceTemplateReferenceSchema", "ResourceUpdatedNotificationSchema", "ResourceUpdatedNotificationParamsSchema", "ResultSchema", "RoleSchema", "RootSchema", "RootsListChangedNotificationSchema", "SamplingContentSchema", "SamplingMessageSchema", "SamplingMessageContentBlockSchema", "ServerCapabilitiesSchema", "ServerNotificationSchema", "ServerRequestSchema", "ServerResultSchema", "SetLevelRequestSchema", "SetLevelRequestParamsSchema", "SingleSelectEnumSchemaSchema", "StringSchemaSchema", "SubscribeRequestSchema", "SubscribeRequestParamsSchema", "TaskSchema", "TaskAugmentedRequestParamsSchema", "TaskCreationParamsSchema", "TaskMetadataSchema", "TaskStatusSchema", "TaskStatusNotificationSchema", "TaskStatusNotificationParamsSchema", "TextContentSchema", "TextResourceContentsSchema", "TitledMultiSelectEnumSchemaSchema", "TitledSingleSelectEnumSchemaSchema", "ToolSchema", "ToolAnnotationsSchema", "ToolChoiceSchema", "ToolExecutionSchema", "ToolListChangedNotificationSchema", "ToolResultContentSchema", "ToolUseContentSchema", "UnsubscribeRequestSchema", "UnsubscribeRequestParamsSchema", "UntitledMultiSelectEnumSchemaSchema", "UntitledSingleSelectEnumSchemaSchema"]; + +// @public (undocumented) +export const STDIO_DEFAULT_MAX_BUFFER_SIZE: number; + +// @public (undocumented) +export const SUPPORTED_PROTOCOL_VERSIONS: string[]; + +// @public (undocumented) +export type SamplingContent = Infer; + +// @public +const SamplingContentSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"text">; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>], "type">; + +// @public (undocumented) +export type SamplingMessage = Infer; + +// @public (undocumented) +export type SamplingMessageContentBlock = Infer; + +// @public +const SamplingMessageContentBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"text">; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; +}, z.core.$strip>], "type">; + +// @public +const SamplingMessageSchema: z.ZodObject<{ + role: z.ZodEnum<{ + user: "user"; + assistant: "assistant"; + }>; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +type SchemaFor = K extends ProtocolSchemaKey ? (typeof schemas_d_exports)[K] : K extends AuthSchemaKey ? (typeof authSchemas)[K] : never; + +// @public (undocumented) +type SchemaKey = ProtocolSchemaKey | AuthSchemaKey; + +// @public (undocumented) +type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync }; + +// @public +export class SdkError extends Error { + constructor(code: SdkErrorCode, message: string, data?: unknown | undefined); + // (undocumented) + readonly code: SdkErrorCode; + // (undocumented) + readonly data?: unknown | undefined; +} + +// @public +export enum SdkErrorCode { + AlreadyConnected = "ALREADY_CONNECTED", + CapabilityNotSupported = "CAPABILITY_NOT_SUPPORTED", + // (undocumented) + ClientHttpAuthentication = "CLIENT_HTTP_AUTHENTICATION", + // (undocumented) + ClientHttpFailedToOpenStream = "CLIENT_HTTP_FAILED_TO_OPEN_STREAM", + // (undocumented) + ClientHttpFailedToTerminateSession = "CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION", + // (undocumented) + ClientHttpForbidden = "CLIENT_HTTP_FORBIDDEN", + // (undocumented) + ClientHttpNotImplemented = "CLIENT_HTTP_NOT_IMPLEMENTED", + // (undocumented) + ClientHttpUnexpectedContent = "CLIENT_HTTP_UNEXPECTED_CONTENT", + ConnectionClosed = "CONNECTION_CLOSED", + InvalidResult = "INVALID_RESULT", + NotConnected = "NOT_CONNECTED", + NotInitialized = "NOT_INITIALIZED", + RequestTimeout = "REQUEST_TIMEOUT", + SendFailed = "SEND_FAILED", +} + +// @public +export class SdkHttpError extends SdkError { + constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData); + // (undocumented) + readonly data: SdkHttpErrorData; + // (undocumented) + get status(): number; + // (undocumented) + get statusText(): string | undefined; +} + +// @public +export interface SdkHttpErrorData { + // (undocumented) + [key: string]: unknown; + // (undocumented) + status: number; + // (undocumented) + statusText?: string; +} + +// @public @deprecated +export class Server extends Protocol { + constructor(_serverInfo: Implementation, options?: ServerOptions); + // (undocumented) + protected assertCapabilityForMethod(method: RequestMethod | string): void; + // (undocumented) + protected assertNotificationCapability(method: NotificationMethod | string): void; + // (undocumented) + protected assertRequestHandlerCapability(method: string): void; + // (undocumented) + protected buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext; + createElicitationCompletionNotifier(elicitationId: string, options?: NotificationOptions_2): () => Promise; + createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; + createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; + createMessage(params: CreateMessageRequest['params'], options?: RequestOptions): Promise; + elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise; + getCapabilities(): ServerCapabilities; + getClientCapabilities(): ClientCapabilities | undefined; + getClientVersion(): Implementation | undefined; + getNegotiatedProtocolVersion(): string | undefined; + // (undocumented) + listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise<{ + [x: string]: unknown; + roots: { + uri: string; + name?: string | undefined; + _meta?: Record | undefined; + }[]; + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + }>; + oninitialized?: () => void; + // (undocumented) + ping(): Promise<{ + _meta?: { + [x: string]: unknown; + progressToken?: string | number | undefined; + "io.modelcontextprotocol/related-task"?: { + taskId: string; + } | undefined; + } | undefined; + resultType?: string | undefined; + }>; + registerCapabilities(capabilities: ServerCapabilities): void; + sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string): Promise; + // (undocumented) + sendPromptListChanged(): Promise; + // (undocumented) + sendResourceListChanged(): Promise; + // (undocumented) + sendResourceUpdated(params: ResourceUpdatedNotification['params']): Promise; + // (undocumented) + sendToolListChanged(): Promise; + protected _wrapHandler(method: string, handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise): (request: JSONRPCRequest, ctx: ServerContext) => Promise; +} + +// @public (undocumented) +export type ServerCapabilities = Infer; + +// @public +const ServerCapabilitiesSchema: z.ZodObject<{ + experimental: z.ZodOptional>>>; + logging: z.ZodOptional>>; + completions: z.ZodOptional>>; + prompts: z.ZodOptional; + }, z.core.$strip>>; + resources: z.ZodOptional; + listChanged: z.ZodOptional; + }, z.core.$strip>>; + tools: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; +}, z.core.$strip>; + +// @public +export type ServerContext = BaseContext & { + mcpReq: { + log: (level: LoggingLevel, data: unknown, logger?: string) => Promise; + elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; + requestSampling: (params: CreateMessageRequest['params'], options?: RequestOptions) => Promise; + }; + http?: { + req?: globalThis.Request; + closeSSE?: () => void; + closeStandaloneSSE?: () => void; + }; +}; + +// @public (undocumented) +export type ServerNotification = Infer; + +// @public (undocumented) +const ServerNotificationSchema: z.ZodUnion; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + requestId: z.ZodOptional>; + reason: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/progress">; + params: z.ZodObject<{ + progressToken: z.ZodUnion; + progress: z.ZodNumber; + total: z.ZodOptional; + message: z.ZodOptional; + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/message">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; + logger: z.ZodOptional; + data: z.ZodUnknown; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/resources/updated">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/resources/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/tools/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/prompts/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/tasks/status">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"notifications/elicitation/complete">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + elicitationId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type ServerOptions = ProtocolOptions & { + capabilities?: ServerCapabilities; + instructions?: string; + jsonSchemaValidator?: jsonSchemaValidator; +}; + +// @public (undocumented) +export type ServerRequest = Infer; + +// @public (undocumented) +const ServerRequestSchema: z.ZodUnion; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"sampling/createMessage">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + messages: z.ZodArray; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">, z.ZodArray; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; + }, z.core.$strip>], "type">>]>; + _meta: z.ZodOptional>; + }, z.core.$strip>>; + modelPreferences: z.ZodOptional; + }, z.core.$strip>>>; + costPriority: z.ZodOptional; + speedPriority: z.ZodOptional; + intelligencePriority: z.ZodOptional; + }, z.core.$strip>>; + systemPrompt: z.ZodOptional; + includeContext: z.ZodOptional>; + temperature: z.ZodOptional; + maxTokens: z.ZodNumber; + stopSequences: z.ZodOptional>; + metadata: z.ZodOptional>>; + tools: z.ZodOptional; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>>; + toolChoice: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"elicitation/create">; + params: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodOptional>; + message: z.ZodString; + requestedSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodRecord; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + enumNames: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; + }, z.core.$strip>]>, z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; + }, z.core.$strip>]>]>, z.ZodObject<{ + type: z.ZodLiteral<"boolean">; + title: z.ZodOptional; + description: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodEnum<{ + number: "number"; + integer: "integer"; + }>; + title: z.ZodOptional; + description: z.ZodOptional; + minimum: z.ZodOptional; + maximum: z.ZodOptional; + default: z.ZodOptional; + }, z.core.$strip>]>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + }, z.core.$strip>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; + mode: z.ZodLiteral<"url">; + message: z.ZodString; + elicitationId: z.ZodString; + url: z.ZodString; + }, z.core.$strip>]>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"roots/list">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/get">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/result">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>, z.ZodObject<{ + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + cursor: z.ZodOptional; + }, z.core.$strip>>; + method: z.ZodLiteral<"tasks/list">; +}, z.core.$strip>, z.ZodObject<{ + method: z.ZodLiteral<"tasks/cancel">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>]>; + +// @public (undocumented) +export type ServerResult = Infer; + +// @public (undocumented) +const ServerResultSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$strict>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + protocolVersion: z.ZodString; + capabilities: z.ZodObject<{ + experimental: z.ZodOptional>>>; + logging: z.ZodOptional>>; + completions: z.ZodOptional>>; + prompts: z.ZodOptional; + }, z.core.$strip>>; + resources: z.ZodOptional; + listChanged: z.ZodOptional; + }, z.core.$strip>>; + tools: z.ZodOptional; + }, z.core.$strip>>; + tasks: z.ZodOptional>>; + cancel: z.ZodOptional>>; + requests: z.ZodOptional>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + }, z.core.$loose>>; + extensions: z.ZodOptional>>>; + }, z.core.$strip>; + serverInfo: z.ZodObject<{ + version: z.ZodString; + websiteUrl: z.ZodOptional; + description: z.ZodOptional; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>; + instructions: z.ZodOptional; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + completion: z.ZodObject<{ + values: z.ZodArray; + total: z.ZodOptional; + hasMore: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + description: z.ZodOptional; + messages: z.ZodArray; + content: z.ZodUnion; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + prompts: z.ZodArray; + arguments: z.ZodOptional; + required: z.ZodOptional; + }, z.core.$strip>>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + resources: z.ZodArray; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + resourceTemplates: z.ZodArray; + mimeType: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + contents: z.ZodArray; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + tools: z.ZodArray; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + nextCursor: z.ZodOptional; + tasks: z.ZodArray; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$loose>, z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + task: z.ZodObject<{ + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$loose>]>; + +// @public (undocumented) +export type SetLevelRequest = Infer; + +// @public (undocumented) +export type SetLevelRequestParams = Infer; + +// @public +const SetLevelRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; +}, z.core.$strip>; + +// @public +const SetLevelRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"logging/setLevel">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + level: z.ZodEnum<{ + error: "error"; + debug: "debug"; + info: "info"; + notice: "notice"; + warning: "warning"; + critical: "critical"; + alert: "alert"; + emergency: "emergency"; + }>; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type SingleSelectEnumSchema = Infer; + +// @public (undocumented) +const SingleSelectEnumSchemaSchema: z.ZodUnion; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; +}, z.core.$strip>]>; + +// @public +type SpecTypeInputs = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.input> : never }; + +// @public +export type SpecTypeName = StripSchemaSuffix; + +// @public +export type SpecTypes = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.output> : never }; + +// @public (undocumented) +interface StandardJSONSchemaV1 { + // (undocumented) + readonly '~standard': StandardJSONSchemaV1.Props; +} + +// @public (undocumented) +namespace StandardJSONSchemaV1 { + // (undocumented) + interface Converter { + // (undocumented) + readonly input: (options: Options) => Record; + // (undocumented) + readonly output: (options: Options) => Record; + } + // (undocumented) + type InferInput = StandardTypedV1.InferInput; + // (undocumented) + type InferOutput = StandardTypedV1.InferOutput; + // (undocumented) + interface Options { + // (undocumented) + readonly libraryOptions?: Record | undefined; + // (undocumented) + readonly target: Target; + } + // (undocumented) + interface Props extends StandardTypedV1.Props { + // (undocumented) + readonly jsonSchema: Converter; + } + // (undocumented) + type Target = 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | (object & string); +} + +// @public (undocumented) +export interface StandardSchemaV1 { + // (undocumented) + readonly '~standard': StandardSchemaV1.Props; +} + +// @public (undocumented) +export namespace StandardSchemaV1 { + // (undocumented) + export interface FailureResult { + // (undocumented) + readonly issues: ReadonlyArray; + } + // (undocumented) + export type InferInput = StandardTypedV1.InferInput; + // (undocumented) + export type InferOutput = StandardTypedV1.InferOutput; + // (undocumented) + export interface Issue { + // (undocumented) + readonly message: string; + // (undocumented) + readonly path?: ReadonlyArray | undefined; + } + // (undocumented) + export interface Options { + // (undocumented) + readonly libraryOptions?: Record | undefined; + } + // (undocumented) + export interface PathSegment { + // (undocumented) + readonly key: PropertyKey; + } + // (undocumented) + export interface Props extends StandardTypedV1.Props { + // (undocumented) + readonly validate: (value: unknown, options?: Options | undefined) => Result | Promise>; + } + // (undocumented) + export type Result = SuccessResult | FailureResult; + // (undocumented) + export interface SuccessResult { + // (undocumented) + readonly issues?: undefined; + // (undocumented) + readonly value: Output; + } +} + +// @public +export interface StandardSchemaV1Sync extends StandardSchemaV1 { + // (undocumented) + readonly '~standard': StandardSchemaV1Sync.Props; +} + +// @public (undocumented) +export namespace StandardSchemaV1Sync { + // (undocumented) + export type InferInput = StandardTypedV1.InferInput; + // (undocumented) + export type InferOutput = StandardTypedV1.InferOutput; + // (undocumented) + export interface Props extends StandardSchemaV1.Props { + // (undocumented) + readonly validate: (value: unknown, options?: StandardSchemaV1.Options | undefined) => StandardSchemaV1.Result; + } +} + +// @public +export interface StandardSchemaWithJSON { + // (undocumented) + readonly '~standard': StandardSchemaV1.Props & StandardJSONSchemaV1.Props; +} + +// @public (undocumented) +export namespace StandardSchemaWithJSON { + // (undocumented) + export type InferInput = StandardTypedV1.InferInput; + // (undocumented) + export type InferOutput = StandardTypedV1.InferOutput; +} + +// @public +interface StandardTypedV1 { + // (undocumented) + readonly '~standard': StandardTypedV1.Props; +} + +// @public (undocumented) +namespace StandardTypedV1 { + // (undocumented) + type InferInput = NonNullable['input']; + // (undocumented) + type InferOutput = NonNullable['output']; + // (undocumented) + interface Props { + // (undocumented) + readonly types?: Types | undefined; + // (undocumented) + readonly vendor: string; + // (undocumented) + readonly version: 1; + } + // (undocumented) + interface Types { + // (undocumented) + readonly input: Input; + // (undocumented) + readonly output: Output; + } +} + +// @public (undocumented) +export type StreamId = string; + +// @public (undocumented) +export type StringSchema = Infer; + +// @public +const StringSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + minLength: z.ZodOptional; + maxLength: z.ZodOptional; + format: z.ZodOptional>; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +type StripSchemaSuffix = K extends `${infer N}Schema` ? N : never; + +// @public (undocumented) +export type SubscribeRequest = Infer; + +// @public (undocumented) +export type SubscribeRequestParams = Infer; + +// @public (undocumented) +const SubscribeRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const SubscribeRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"resources/subscribe">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public (undocumented) +export type Task = Infer; + +// @public (undocumented) +export type TaskAugmentedRequestParams = Infer; + +// @public +const TaskAugmentedRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + task: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type TaskCreationParams = Infer; + +// @public +const TaskCreationParamsSchema: z.ZodObject<{ + ttl: z.ZodOptional; + pollInterval: z.ZodOptional; +}, z.core.$loose>; + +// @public (undocumented) +export type TaskMetadata = Infer; + +// @public (undocumented) +const TaskMetadataSchema: z.ZodObject<{ + ttl: z.ZodOptional; +}, z.core.$strip>; + +// @public +const TaskSchema: z.ZodObject<{ + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type TaskStatus = Infer; + +// @public (undocumented) +export type TaskStatusNotification = Infer; + +// @public (undocumented) +export type TaskStatusNotificationParams = Infer; + +// @public +const TaskStatusNotificationParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; +}, z.core.$strip>; + +// @public +const TaskStatusNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/tasks/status">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + taskId: z.ZodString; + status: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; + }>; + ttl: z.ZodUnion; + createdAt: z.ZodString; + lastUpdatedAt: z.ZodString; + pollInterval: z.ZodOptional; + statusMessage: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public +const TaskStatusSchema: z.ZodEnum<{ + working: "working"; + input_required: "input_required"; + completed: "completed"; + failed: "failed"; + cancelled: "cancelled"; +}>; + +// @public (undocumented) +export type TextContent = Infer; + +// @public +const TextContentSchema: z.ZodObject<{ + type: z.ZodLiteral<"text">; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type TextResourceContents = Infer; + +// @public (undocumented) +const TextResourceContentsSchema: z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + text: z.ZodString; +}, z.core.$strip>; + +// @public (undocumented) +export type TitledMultiSelectEnumSchema = Infer; + +// @public +const TitledMultiSelectEnumSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + anyOf: z.ZodArray>; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type TitledSingleSelectEnumSchema = Infer; + +// @public +const TitledSingleSelectEnumSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + oneOf: z.ZodArray>; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type Tool = Infer; + +// @public (undocumented) +export type ToolAnnotations = Infer; + +// @public +const ToolAnnotationsSchema: z.ZodObject<{ + title: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; +}, z.core.$strip>; + +// @public +export type ToolCallback = BaseToolCallback; + +// @public (undocumented) +export type ToolChoice = Infer; + +// @public +const ToolChoiceSchema: z.ZodObject<{ + mode: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type ToolExecution = Infer; + +// @public +const ToolExecutionSchema: z.ZodObject<{ + taskSupport: z.ZodOptional>; +}, z.core.$strip>; + +// @public +type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; + +// @public (undocumented) +export type ToolListChangedNotification = Infer; + +// @public +const ToolListChangedNotificationSchema: z.ZodObject<{ + method: z.ZodLiteral<"notifications/tools/list_changed">; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$strip>>; +}, z.core.$strip>; + +// @public (undocumented) +export type ToolResultContent = Infer; + +// @public +const ToolResultContentSchema: z.ZodObject<{ + type: z.ZodLiteral<"tool_result">; + toolUseId: z.ZodString; + content: z.ZodDefault; + text: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"image">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"audio">; + data: z.ZodString; + mimeType: z.ZodString; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + description: z.ZodOptional; + mimeType: z.ZodOptional; + size: z.ZodOptional; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; + type: z.ZodLiteral<"resource_link">; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"resource">; + resource: z.ZodUnion; + _meta: z.ZodOptional>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + uri: z.ZodString; + mimeType: z.ZodOptional; + _meta: z.ZodOptional>; + blob: z.ZodString; + }, z.core.$strip>]>; + annotations: z.ZodOptional>>; + priority: z.ZodOptional; + lastModified: z.ZodOptional; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + }, z.core.$strip>]>>>; + structuredContent: z.ZodOptional>; + isError: z.ZodOptional; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public +const ToolSchema: z.ZodObject<{ + description: z.ZodOptional; + inputSchema: z.ZodObject<{ + type: z.ZodLiteral<"object">; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>; + outputSchema: z.ZodOptional; + properties: z.ZodOptional>>>; + required: z.ZodOptional>; + }, z.core.$catchall>>; + annotations: z.ZodOptional; + readOnlyHint: z.ZodOptional; + destructiveHint: z.ZodOptional; + idempotentHint: z.ZodOptional; + openWorldHint: z.ZodOptional; + }, z.core.$strip>>; + execution: z.ZodOptional>; + }, z.core.$strip>>; + _meta: z.ZodOptional>; + icons: z.ZodOptional; + sizes: z.ZodOptional>; + theme: z.ZodOptional>; + }, z.core.$strip>>>; + name: z.ZodString; + title: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type ToolUseContent = Infer; + +// @public +const ToolUseContentSchema: z.ZodObject<{ + type: z.ZodLiteral<"tool_use">; + name: z.ZodString; + id: z.ZodString; + input: z.ZodRecord; + _meta: z.ZodOptional>; +}, z.core.$strip>; + +// @public +export interface Transport { + close(): Promise; + onclose?: (() => void) | undefined; + onerror?: ((error: Error) => void) | undefined; + onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; + sessionId?: string | undefined; + setProtocolVersion?: ((version: string) => void) | undefined; + setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; + start(): Promise; +} + +// @public +export type TransportSendOptions = { + relatedRequestId?: RequestId | undefined; + resumptionToken?: string | undefined; + onresumptiontoken?: ((token: string) => void) | undefined; +}; + +// @public (undocumented) +export type UnsubscribeRequest = Infer; + +// @public (undocumented) +export type UnsubscribeRequestParams = Infer; + +// @public (undocumented) +const UnsubscribeRequestParamsSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; +}, z.core.$strip>; + +// @public +const UnsubscribeRequestSchema: z.ZodObject<{ + method: z.ZodLiteral<"resources/unsubscribe">; + params: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + uri: z.ZodString; + }, z.core.$strip>; +}, z.core.$strip>; + +// @public +export class UnsupportedProtocolVersionError extends ProtocolError { + constructor(data: UnsupportedProtocolVersionErrorData, message?: string); + get requested(): string; + get supported(): string[]; +} + +// @public +export interface UnsupportedProtocolVersionErrorData { + requested: string; + supported: string[]; +} + +// @public (undocumented) +export type UntitledMultiSelectEnumSchema = Infer; + +// @public +const UntitledMultiSelectEnumSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"array">; + title: z.ZodOptional; + description: z.ZodOptional; + minItems: z.ZodOptional; + maxItems: z.ZodOptional; + items: z.ZodObject<{ + type: z.ZodLiteral<"string">; + enum: z.ZodArray; + }, z.core.$strip>; + default: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type UntitledSingleSelectEnumSchema = Infer; + +// @public +const UntitledSingleSelectEnumSchemaSchema: z.ZodObject<{ + type: z.ZodLiteral<"string">; + title: z.ZodOptional; + description: z.ZodOptional; + enum: z.ZodArray; + default: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export class UriTemplate { + constructor(template: string); + // (undocumented) + expand(variables: Variables): string; + static isTemplate(str: string): boolean; + // (undocumented) + match(uri: string): Variables | null; + // (undocumented) + toString(): string; + // (undocumented) + get variableNames(): string[]; +} + +// @public +export class UrlElicitationRequiredError extends ProtocolError { + constructor(elicitations: ElicitRequestURLParams[], message?: string); + // (undocumented) + get elicitations(): ElicitRequestURLParams[]; +} + +// @public (undocumented) +export type Variables = Record; + +// @public +export class WebStandardStreamableHTTPServerTransport implements Transport { + constructor(options?: WebStandardStreamableHTTPServerTransportOptions); + // (undocumented) + close(): Promise; + closeSSEStream(requestId: RequestId): void; + closeStandaloneSSEStream(): void; + handleRequest(req: Request, options?: HandleRequestOptions): Promise; + // (undocumented) + onclose?: () => void; + // (undocumented) + onerror?: (error: Error) => void; + // (undocumented) + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + // (undocumented) + send(message: JSONRPCMessage, options?: { + relatedRequestId?: RequestId; + }): Promise; + // (undocumented) + sessionId?: string; + setSupportedProtocolVersions(versions: string[]): void; + start(): Promise; +} + +// @public +export interface WebStandardStreamableHTTPServerTransportOptions { + // @deprecated + allowedHosts?: string[]; + // @deprecated + allowedOrigins?: string[]; + // @deprecated + enableDnsRebindingProtection?: boolean; + enableJsonResponse?: boolean; + eventStore?: EventStore; + onsessionclosed?: ((sessionId: string) => void | Promise) | undefined; + onsessioninitialized?: ((sessionId: string) => void | Promise) | undefined; + retryInterval?: number; + sessionIdGenerator?: (() => string) | undefined; + supportedProtocolVersions?: string[]; +} + +// @public +type ZodRawShape = Record; + +// @public (undocumented) +export function assertCompleteRequestPrompt(request: CompleteRequest): asserts request is CompleteRequestPrompt; + +// @public (undocumented) +export function assertCompleteRequestResourceTemplate(request: CompleteRequest): asserts request is CompleteRequestResourceTemplate; + +// @public (undocumented) +const authSchemas: { + readonly OAuthClientInformationFullSchema: z.ZodObject<{ + redirect_uris: z.ZodArray; + token_endpoint_auth_method: z.ZodOptional; + grant_types: z.ZodOptional>; + response_types: z.ZodOptional>; + client_name: z.ZodOptional; + client_uri: z.ZodOptional; + logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + scope: z.ZodOptional; + contacts: z.ZodOptional>; + tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + policy_uri: z.ZodOptional; + jwks_uri: z.ZodOptional; + jwks: z.ZodOptional; + software_id: z.ZodOptional; + software_version: z.ZodOptional; + software_statement: z.ZodOptional; + client_id: z.ZodString; + client_secret: z.ZodOptional; + client_id_issued_at: z.ZodOptional; + client_secret_expires_at: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthClientInformationSchema: z.ZodObject<{ + client_id: z.ZodString; + client_secret: z.ZodOptional; + client_id_issued_at: z.ZodOptional; + client_secret_expires_at: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthClientMetadataSchema: z.ZodObject<{ + redirect_uris: z.ZodArray; + token_endpoint_auth_method: z.ZodOptional; + grant_types: z.ZodOptional>; + response_types: z.ZodOptional>; + client_name: z.ZodOptional; + client_uri: z.ZodOptional; + logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + scope: z.ZodOptional; + contacts: z.ZodOptional>; + tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; + policy_uri: z.ZodOptional; + jwks_uri: z.ZodOptional; + jwks: z.ZodOptional; + software_id: z.ZodOptional; + software_version: z.ZodOptional; + software_statement: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthClientRegistrationErrorSchema: z.ZodObject<{ + error: z.ZodString; + error_description: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthErrorResponseSchema: z.ZodObject<{ + error: z.ZodString; + error_description: z.ZodOptional; + error_uri: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + revocation_endpoint: z.ZodOptional; + revocation_endpoint_auth_methods_supported: z.ZodOptional>; + revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + introspection_endpoint: z.ZodOptional; + introspection_endpoint_auth_methods_supported: z.ZodOptional>; + introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + code_challenge_methods_supported: z.ZodOptional>; + client_id_metadata_document_supported: z.ZodOptional; + }, z.core.$loose>; + readonly OAuthProtectedResourceMetadataSchema: z.ZodObject<{ + resource: z.ZodString; + authorization_servers: z.ZodOptional>; + jwks_uri: z.ZodOptional; + scopes_supported: z.ZodOptional>; + bearer_methods_supported: z.ZodOptional>; + resource_signing_alg_values_supported: z.ZodOptional>; + resource_name: z.ZodOptional; + resource_documentation: z.ZodOptional; + resource_policy_uri: z.ZodOptional; + resource_tos_uri: z.ZodOptional; + tls_client_certificate_bound_access_tokens: z.ZodOptional; + authorization_details_types_supported: z.ZodOptional>; + dpop_signing_alg_values_supported: z.ZodOptional>; + dpop_bound_access_tokens_required: z.ZodOptional; + }, z.core.$loose>; + readonly OAuthTokenRevocationRequestSchema: z.ZodObject<{ + token: z.ZodString; + token_type_hint: z.ZodOptional; + }, z.core.$strip>; + readonly OAuthTokensSchema: z.ZodObject<{ + access_token: z.ZodString; + id_token: z.ZodOptional; + token_type: z.ZodString; + expires_in: z.ZodOptional>; + scope: z.ZodOptional; + refresh_token: z.ZodOptional; + }, z.core.$strip>; + readonly OpenIdProviderDiscoveryMetadataSchema: z.ZodObject<{ + code_challenge_methods_supported: z.ZodOptional>; + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + userinfo_endpoint: z.ZodOptional; + jwks_uri: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + acr_values_supported: z.ZodOptional>; + subject_types_supported: z.ZodArray; + id_token_signing_alg_values_supported: z.ZodArray; + id_token_encryption_alg_values_supported: z.ZodOptional>; + id_token_encryption_enc_values_supported: z.ZodOptional>; + userinfo_signing_alg_values_supported: z.ZodOptional>; + userinfo_encryption_alg_values_supported: z.ZodOptional>; + userinfo_encryption_enc_values_supported: z.ZodOptional>; + request_object_signing_alg_values_supported: z.ZodOptional>; + request_object_encryption_alg_values_supported: z.ZodOptional>; + request_object_encryption_enc_values_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + display_values_supported: z.ZodOptional>; + claim_types_supported: z.ZodOptional>; + claims_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + claims_locales_supported: z.ZodOptional>; + ui_locales_supported: z.ZodOptional>; + claims_parameter_supported: z.ZodOptional; + request_parameter_supported: z.ZodOptional; + request_uri_parameter_supported: z.ZodOptional; + require_request_uri_registration: z.ZodOptional; + op_policy_uri: z.ZodOptional; + op_tos_uri: z.ZodOptional; + client_id_metadata_document_supported: z.ZodOptional; + }, z.core.$strip>; + readonly OpenIdProviderMetadataSchema: z.ZodObject<{ + issuer: z.ZodString; + authorization_endpoint: z.ZodURL; + token_endpoint: z.ZodURL; + userinfo_endpoint: z.ZodOptional; + jwks_uri: z.ZodURL; + registration_endpoint: z.ZodOptional; + scopes_supported: z.ZodOptional>; + response_types_supported: z.ZodArray; + response_modes_supported: z.ZodOptional>; + grant_types_supported: z.ZodOptional>; + acr_values_supported: z.ZodOptional>; + subject_types_supported: z.ZodArray; + id_token_signing_alg_values_supported: z.ZodArray; + id_token_encryption_alg_values_supported: z.ZodOptional>; + id_token_encryption_enc_values_supported: z.ZodOptional>; + userinfo_signing_alg_values_supported: z.ZodOptional>; + userinfo_encryption_alg_values_supported: z.ZodOptional>; + userinfo_encryption_enc_values_supported: z.ZodOptional>; + request_object_signing_alg_values_supported: z.ZodOptional>; + request_object_encryption_alg_values_supported: z.ZodOptional>; + request_object_encryption_enc_values_supported: z.ZodOptional>; + token_endpoint_auth_methods_supported: z.ZodOptional>; + token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; + display_values_supported: z.ZodOptional>; + claim_types_supported: z.ZodOptional>; + claims_supported: z.ZodOptional>; + service_documentation: z.ZodOptional; + claims_locales_supported: z.ZodOptional>; + ui_locales_supported: z.ZodOptional>; + claims_parameter_supported: z.ZodOptional; + request_parameter_supported: z.ZodOptional; + request_uri_parameter_supported: z.ZodOptional; + require_request_uri_registration: z.ZodOptional; + op_policy_uri: z.ZodOptional; + op_tos_uri: z.ZodOptional; + client_id_metadata_document_supported: z.ZodOptional; + }, z.core.$loose>; +}; + +// @public +export function checkResourceAllowed(input: { + requestedResource: URL | string; + configuredResource: URL | string; +}): boolean; + +// @public +export function completable(schema: T, complete: CompleteCallback): CompletableSchema; + +// @public +export function createFetchWithInit(baseFetch?: FetchLike, baseInit?: RequestInit): FetchLike; + +// @public (undocumented) +export function deserializeMessage(line: string): JSONRPCMessage; + +// @public (undocumented) +export function fromJsonSchema(schema: JsonSchemaType, validator?: jsonSchemaValidator): StandardSchemaWithJSON; + +// @public +export function getDisplayName(metadata: BaseMetadata | (BaseMetadata & { + annotations?: { + title?: string; + }; +})): string; + +// @public +export function hostHeaderValidationResponse(req: Request, allowedHostnames: string[]): Response | undefined; + +// @public +export const isCallToolResult: (value: unknown) => value is CallToolResult; + +// @public +export function isCompletable(schema: unknown): schema is CompletableSchema; + +// @public (undocumented) +export const isInitializeRequest: (value: unknown) => value is InitializeRequest; + +// @public (undocumented) +export const isInitializedNotification: (value: unknown) => value is InitializedNotification; + +// @public +export const isJSONRPCErrorResponse: (value: unknown) => value is JSONRPCErrorResponse; + +// @public (undocumented) +export const isJSONRPCNotification: (value: unknown) => value is JSONRPCNotification; + +// @public (undocumented) +export const isJSONRPCRequest: (value: unknown) => value is JSONRPCRequest; + +// @public +export const isJSONRPCResponse: (value: unknown) => value is JSONRPCResponse; + +// @public +export const isJSONRPCResultResponse: (value: unknown) => value is JSONRPCResultResponse; + +// @public +export const isSpecType: GuardRecord; + +// @public +export const isTaskAugmentedRequestParams: (value: unknown) => value is TaskAugmentedRequestParams; + +// @public +export interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +export function localhostAllowedHostnames(): string[]; + +// @public +export function parseJSONRPCMessage(value: unknown): JSONRPCMessage; + +// @public +export function resourceUrlFromServerUrl(url: URL | string): URL; + +// @public (undocumented) +namespace schemas_d_exports { + export { AnnotationsSchema, AudioContentSchema, BaseMetadataSchema, BaseRequestParamsSchema, BlobResourceContentsSchema, BooleanSchemaSchema, CallToolRequestParamsSchema, CallToolRequestSchema, CallToolResultSchema, CancelTaskRequestSchema, CancelTaskResultSchema, CancelledNotificationParamsSchema, CancelledNotificationSchema, ClientCapabilitiesSchema, ClientNotificationSchema, ClientRequestSchema, ClientResultSchema, ClientTasksCapabilitySchema, CompatibilityCallToolResultSchema, CompleteRequestParamsSchema, CompleteRequestSchema, CompleteResultSchema, ContentBlockSchema, CreateMessageRequestParamsSchema, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, CreateTaskResultSchema, CursorSchema, DiscoverRequestSchema, DiscoverResultSchema, ElicitRequestFormParamsSchema, ElicitRequestParamsSchema, ElicitRequestSchema, ElicitRequestURLParamsSchema, ElicitResultSchema, ElicitationCompleteNotificationParamsSchema, ElicitationCompleteNotificationSchema, EmbeddedResourceSchema, EmptyResultSchema, EnumSchemaSchema, GetPromptRequestParamsSchema, GetPromptRequestSchema, GetPromptResultSchema, GetTaskPayloadRequestSchema, GetTaskPayloadResultSchema, GetTaskRequestSchema, GetTaskResultSchema, IconSchema, IconsSchema, ImageContentSchema, ImplementationSchema, InitializeRequestParamsSchema, InitializeRequestSchema, InitializeResultSchema, InitializedNotificationSchema, JSONArraySchema, JSONObjectSchema, JSONRPCErrorResponseSchema, JSONRPCMessageSchema, JSONRPCNotificationSchema, JSONRPCRequestSchema, JSONRPCResponseSchema, JSONRPCResultResponseSchema, JSONValueSchema, LegacyTitledEnumSchemaSchema, ListChangedOptionsBaseSchema, ListPromptsRequestSchema, ListPromptsResultSchema, ListResourceTemplatesRequestSchema, ListResourceTemplatesResultSchema, ListResourcesRequestSchema, ListResourcesResultSchema, ListRootsRequestSchema, ListRootsResultSchema, ListTasksRequestSchema, ListTasksResultSchema, ListToolsRequestSchema, ListToolsResultSchema, LoggingLevelSchema, LoggingMessageNotificationParamsSchema, LoggingMessageNotificationSchema, ModelHintSchema, ModelPreferencesSchema, MultiSelectEnumSchemaSchema, NotificationSchema, NotificationsParamsSchema, NumberSchemaSchema, PaginatedRequestParamsSchema, PaginatedRequestSchema, PaginatedResultSchema, PingRequestSchema, PrimitiveSchemaDefinitionSchema, ProgressNotificationParamsSchema, ProgressNotificationSchema, ProgressSchema, ProgressTokenSchema, PromptArgumentSchema, PromptListChangedNotificationSchema, PromptMessageSchema, PromptReferenceSchema, PromptSchema, ReadResourceRequestParamsSchema, ReadResourceRequestSchema, ReadResourceResultSchema, RelatedTaskMetadataSchema, RequestIdSchema, RequestMetaEnvelopeSchema, RequestMetaSchema, RequestSchema, ResourceContentsSchema, ResourceLinkSchema, ResourceListChangedNotificationSchema, ResourceRequestParamsSchema, ResourceSchema, ResourceTemplateReferenceSchema, ResourceTemplateSchema, ResourceUpdatedNotificationParamsSchema, ResourceUpdatedNotificationSchema, ResultSchema, RoleSchema, RootSchema, RootsListChangedNotificationSchema, SamplingContentSchema, SamplingMessageContentBlockSchema, SamplingMessageSchema, ServerCapabilitiesSchema, ServerNotificationSchema, ServerRequestSchema, ServerResultSchema, ServerTasksCapabilitySchema, SetLevelRequestParamsSchema, SetLevelRequestSchema, SingleSelectEnumSchemaSchema, StringSchemaSchema, SubscribeRequestParamsSchema, SubscribeRequestSchema, TaskAugmentedRequestParamsSchema, TaskCreationParamsSchema, TaskMetadataSchema, TaskSchema, TaskStatusNotificationParamsSchema, TaskStatusNotificationSchema, TaskStatusSchema, TextContentSchema, TextResourceContentsSchema, TitledMultiSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema, ToolAnnotationsSchema, ToolChoiceSchema, ToolExecutionSchema, ToolListChangedNotificationSchema, ToolResultContentSchema, ToolSchema, ToolUseContentSchema, UnsubscribeRequestParamsSchema, UnsubscribeRequestSchema, UntitledMultiSelectEnumSchemaSchema, UntitledSingleSelectEnumSchemaSchema, getNotificationSchema, getRequestSchema, getResultSchema }; +} + +// @public (undocumented) +export function serializeMessage(message: JSONRPCMessage): string; + +// @public +export const specTypeSchemas: SchemaRecord; + +// @public +export function validateHostHeader(hostHeader: string | null | undefined, allowedHostnames: string[]): HostHeaderValidationResult; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/server/etc/server.shims-workerd.api.md b/packages/server/etc/server.shims-workerd.api.md new file mode 100644 index 0000000000..916749784a --- /dev/null +++ b/packages/server/etc/server.shims-workerd.api.md @@ -0,0 +1,51 @@ +## API Report File for "@modelcontextprotocol/server" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JSONSchema } from 'json-schema-typed'; + +// @public +type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; + +// @public +export class DefaultJsonSchemaValidator implements jsonSchemaValidator { + constructor(options?: { + shortcircuit?: boolean; + draft?: CfWorkerSchemaDraft; + }); + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +type JsonSchemaType = JSONSchema.Interface; + +// @public +type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public +interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public (undocumented) +const process_2: { + readonly stdin: never; + readonly stdout: never; +}; +export { process_2 as process } + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/server/etc/server.shims.api.md b/packages/server/etc/server.shims.api.md new file mode 100644 index 0000000000..d607994689 --- /dev/null +++ b/packages/server/etc/server.shims.api.md @@ -0,0 +1,60 @@ +## API Report File for "@modelcontextprotocol/server" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JSONSchema } from 'json-schema-typed'; +import { default as process_2 } from 'node:process'; + +export { process_2 as process } + +// @public +interface AjvLike { + // (undocumented) + compile: (schema: unknown) => AjvValidateFunction; + // (undocumented) + errorsText: (errors?: any) => string; + // (undocumented) + getSchema: (keyRef: string) => AjvValidateFunction | undefined; +} + +// @public (undocumented) +interface AjvValidateFunction { + // (undocumented) + (input: unknown): boolean; + // (undocumented) + errors?: any; +} + +// @public +export class DefaultJsonSchemaValidator implements jsonSchemaValidator { + constructor(ajv?: AjvLike); + // (undocumented) + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +type JsonSchemaType = JSONSchema.Interface; + +// @public +type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public +interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/server/etc/server.stdio.api.md b/packages/server/etc/server.stdio.api.md new file mode 100644 index 0000000000..8251cfabab --- /dev/null +++ b/packages/server/etc/server.stdio.api.md @@ -0,0 +1,138 @@ +## API Report File for "@modelcontextprotocol/server" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Readable } from 'node:stream'; +import { Writable } from 'node:stream'; +import * as z from 'zod/v4'; + +// @public +interface AuthInfo { + clientId: string; + expiresAt?: number; + extra?: Record; + resource?: URL; + scopes: string[]; + token: string; +} + +// @public (undocumented) +type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; + +// @public (undocumented) +type Infer = Flatten>; + +// @public (undocumented) +type JSONRPCMessage = Infer; + +// @public (undocumented) +const JSONRPCMessageSchema: z.ZodUnion>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; +}, z.core.$strict>, z.ZodObject<{ + method: z.ZodString; + params: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + }, z.core.$loose>>; + jsonrpc: z.ZodLiteral<"2.0">; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodUnion; + result: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; + }, z.core.$loose>; +}, z.core.$strict>, z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>]>; + +// @public +interface MessageExtraInfo { + authInfo?: AuthInfo; + closeSSEStream?: () => void; + closeStandaloneSSEStream?: () => void; + request?: globalThis.Request; +} + +// @public (undocumented) +type Primitive = string | number | boolean | bigint | null | undefined; + +// @public (undocumented) +type RequestId = Infer; + +// @public +const RequestIdSchema: z.ZodUnion; + +// @public +export class StdioServerTransport implements Transport { + constructor(_stdin?: Readable, _stdout?: Writable, options?: { + maxBufferSize?: number; + }); + // (undocumented) + close(): Promise; + // (undocumented) + onclose?: () => void; + // (undocumented) + _ondata: (chunk: Buffer) => void; + // (undocumented) + onerror?: (error: Error) => void; + // (undocumented) + _onerror: (error: Error) => void; + // (undocumented) + onmessage?: (message: JSONRPCMessage) => void; + // (undocumented) + _onstdouterror: (error: Error) => void; + // (undocumented) + send(message: JSONRPCMessage): Promise; + start(): Promise; +} + +// @public +interface Transport { + close(): Promise; + onclose?: (() => void) | undefined; + onerror?: ((error: Error) => void) | undefined; + onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; + sessionId?: string | undefined; + setProtocolVersion?: ((version: string) => void) | undefined; + setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; + start(): Promise; +} + +// @public +type TransportSendOptions = { + relatedRequestId?: RequestId | undefined; + resumptionToken?: string | undefined; + onresumptiontoken?: ((token: string) => void) | undefined; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/server/etc/server.validators-ajv.api.md b/packages/server/etc/server.validators-ajv.api.md new file mode 100644 index 0000000000..2c34e28c16 --- /dev/null +++ b/packages/server/etc/server.validators-ajv.api.md @@ -0,0 +1,1572 @@ +## API Report File for "@modelcontextprotocol/server" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JSONSchema } from 'json-schema-typed'; + +// @public (undocumented) +type AddedFormat = true | RegExp | FormatValidator | FormatDefinition | FormatDefinition | AsyncFormatDefinition | AsyncFormatDefinition; + +// @public (undocumented) +type AddedKeywordDefinition = KeywordDefinition & { + type: JSONType[]; + schemaType: JSONType[]; +}; + +// @public (undocumented) +export class Ajv extends Ajv$2 { + // (undocumented) + _addDefaultMetaSchema(): void; + // (undocumented) + _addVocabularies(): void; + // (undocumented) + defaultMeta(): string | AnySchemaObject | undefined; +} + +// @public (undocumented) +class Ajv$2 { + // (undocumented) + $dataMetaSchema(metaSchema: AnySchemaObject, keywordsJsonPointers: string[]): AnySchemaObject; + constructor(opts?: Options); + // (undocumented) + _addDefaultMetaSchema(): void; + // (undocumented) + addFormat(name: string, format: Format): Ajv$2; + // (undocumented) + addKeyword(kwdOrDef: string | KeywordDefinition, def?: KeywordDefinition): Ajv$2; + // (undocumented) + addMetaSchema(schema: AnySchemaObject, key?: string, + // schema key + _validateSchema?: boolean | "log"): Ajv$2; + // (undocumented) + addSchema(schema: AnySchema | AnySchema[], + // If array is passed, `key` will be ignored + key?: string, + // Optional schema key. Can be passed to `validate` method instead of schema object or id/ref. One schema per instance can have empty `id` and `key`. + _meta?: boolean, + // true if schema is a meta-schema. Used internally, addMetaSchema should be used instead. + _validateSchema?: boolean | "log"): Ajv$2; + // (undocumented) + _addSchema(schema: AnySchema, meta?: boolean, baseId?: string, validateSchema?: boolean | "log", addSchema?: boolean): SchemaEnv; + // (undocumented) + _addVocabularies(): void; + // (undocumented) + addVocabulary(definitions: Vocabulary): Ajv$2; + // (undocumented) + readonly _compilations: Set; + // (undocumented) + compile(schema: Schema | JSONSchemaType, _meta?: boolean): ValidateFunction; + // (undocumented) + compile(schema: JTDSchemaType, _meta?: boolean): ValidateFunction; + // (undocumented) + compile(schema: T, _meta?: boolean): ValidateFunction>; + // (undocumented) + compile(schema: AsyncSchema, _meta?: boolean): AsyncValidateFunction; + // (undocumented) + compile(schema: AnySchema, _meta?: boolean): AnyValidateFunction; + // (undocumented) + compileAsync(schema: SchemaObject | JSONSchemaType, _meta?: boolean): Promise>; + // (undocumented) + compileAsync(schema: JTDSchemaType, _meta?: boolean): Promise>; + // (undocumented) + compileAsync(schema: AsyncSchema, meta?: boolean): Promise>; + // (undocumented) + compileAsync(schema: AnySchemaObject, meta?: boolean): Promise>; + // (undocumented) + defaultMeta(): string | AnySchemaObject | undefined; + // (undocumented) + errors?: ErrorObject[] | null; + // (undocumented) + errorsText(errors?: ErrorObject[] | null | undefined, + // optional array of validation errors + input?: ErrorsTextOptions): string; + // (undocumented) + readonly formats: { [Name in string]?: AddedFormat }; + // (undocumented) + getKeyword(keyword: string): AddedKeywordDefinition | boolean; + // (undocumented) + getSchema(keyRef: string): AnyValidateFunction | undefined; + // (undocumented) + logger: Logger; + // (undocumented) + static MissingRefError: typeof MissingRefError; + // (undocumented) + opts: InstanceOptions; + // (undocumented) + readonly refs: { [Ref in string]?: SchemaEnv | string }; + // (undocumented) + removeKeyword(keyword: string): Ajv$2; + // (undocumented) + removeSchema(schemaKeyRef?: AnySchema | string | RegExp): Ajv$2; + // (undocumented) + readonly RULES: ValidationRules; + // (undocumented) + readonly schemas: { [Key in string]?: SchemaEnv }; + // (undocumented) + readonly scope: ValueScope; + // (undocumented) + validate(schema: Schema | string, data: unknown): boolean; + // (undocumented) + validate(schemaKeyRef: AnySchema | string, data: unknown): boolean | Promise; + // (undocumented) + validate(schema: Schema | JSONSchemaType | string, data: unknown): data is T; + // (undocumented) + validate(schema: JTDSchemaType, data: unknown): data is T; + // (undocumented) + validate(schema: T, data: unknown): data is JTDDataType; + // (undocumented) + validate(schema: AsyncSchema, data: unknown | T): Promise; + // (undocumented) + validate(schemaKeyRef: AnySchema | string, data: unknown): data is T | Promise; + // (undocumented) + validateSchema(schema: AnySchema, throwOrLogError?: boolean): boolean | Promise; + // (undocumented) + static ValidationError: typeof ValidationError; +} + +// @public +export class AjvJsonSchemaValidator implements jsonSchemaValidator { + constructor(ajv?: AjvLike); + // (undocumented) + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +interface AjvLike { + // (undocumented) + compile: (schema: unknown) => AjvValidateFunction; + // (undocumented) + errorsText: (errors?: any) => string; + // (undocumented) + getSchema: (keyRef: string) => AjvValidateFunction | undefined; +} + +// @public (undocumented) +interface AjvValidateFunction { + // (undocumented) + (input: unknown): boolean; + // (undocumented) + errors?: any; +} + +// @public (undocumented) +type AnySchema = Schema | AsyncSchema; + +// @public (undocumented) +type AnySchemaObject = SchemaObject | AsyncSchema; + +// @public (undocumented) +type AnyValidateFunction = ValidateFunction | AsyncValidateFunction; + +// @public (undocumented) +interface AsyncFormatDefinition { + // (undocumented) + async: true; + // (undocumented) + compare?: FormatCompare; + // (undocumented) + type?: T extends string ? "string" | undefined : "number"; + // (undocumented) + validate: AsyncFormatValidator; +} + +// @public (undocumented) +type AsyncFormatValidator = (data: T) => Promise; + +// @public (undocumented) +interface AsyncSchema extends _SchemaObject { + // (undocumented) + $async: true; +} + +// @public (undocumented) +interface AsyncValidateFunction extends ValidateFunction { + // (undocumented) + $async: true; + // (undocumented) + (...args: Parameters>): Promise; +} + +// @public (undocumented) +type Block = Code | (() => void); + +// @public (undocumented) +type Code = _Code | Name; + +// @public (undocumented) +class CodeGen { + constructor(extScope: ValueScope, opts?: CodeGenOptions); + // (undocumented) + add(lhs: Code, rhs: SafeExpr): CodeGen; + // (undocumented) + assign(lhs: Code, rhs: SafeExpr, sideEffects?: boolean): CodeGen; + // (undocumented) + block(body?: Block, nodeCount?: number): CodeGen; + // (undocumented) + break(label?: Code): CodeGen; + // (undocumented) + code(c: Block | SafeExpr): CodeGen; + // (undocumented) + const(nameOrPrefix: Name | string, rhs: SafeExpr, _constant?: boolean): Name; + // (undocumented) + else(): CodeGen; + // (undocumented) + elseIf(condition: Code | boolean): CodeGen; + // (undocumented) + endBlock(nodeCount?: number): CodeGen; + // (undocumented) + endFor(): CodeGen; + // (undocumented) + endFunc(): CodeGen; + // (undocumented) + endIf(): CodeGen; + // (undocumented) + readonly _extScope: ValueScope; + // (undocumented) + for(iteration: Code, forBody?: Block): CodeGen; + // (undocumented) + forIn(nameOrPrefix: Name | string, obj: Code, forBody: (item: Name) => void, varKind?: Code): CodeGen; + // (undocumented) + forOf(nameOrPrefix: Name | string, iterable: Code, forBody: (item: Name) => void, varKind?: Code): CodeGen; + // (undocumented) + forRange(nameOrPrefix: Name | string, from: SafeExpr, to: SafeExpr, forBody: (index: Name) => void, varKind?: Code): CodeGen; + // (undocumented) + func(name: Name, args?: Code, async?: boolean, funcBody?: Block): CodeGen; + // (undocumented) + getScopeValue(prefix: string, keyOrRef: unknown): ValueScopeName | undefined; + // (undocumented) + if(condition: Code | boolean, thenBody?: Block, elseBody?: Block): CodeGen; + // (undocumented) + label(label: Name): CodeGen; + // (undocumented) + let(nameOrPrefix: Name | string, rhs?: SafeExpr, _constant?: boolean): Name; + // (undocumented) + name(prefix: string): Name; + // (undocumented) + object(...keyValues: [Name | string, SafeExpr | string][]): _Code; + // (undocumented) + optimize(n?: number): void; + // (undocumented) + return(value: Block | SafeExpr): CodeGen; + // (undocumented) + readonly _scope: Scope; + // (undocumented) + scopeCode(): Code; + // (undocumented) + scopeName(prefix: string): ValueScopeName; + // (undocumented) + scopeRefs(scopeName: Name): Code; + // (undocumented) + scopeValue(prefixOrName: ValueScopeName | string, value: NameValue): Name; + // (undocumented) + throw(error: Code): CodeGen; + // (undocumented) + toString(): string; + // (undocumented) + try(tryBody: Block, catchCode?: (e: Name) => void, finallyCode?: Block): CodeGen; + // (undocumented) + readonly _values: ScopeValueSets; + // (undocumented) + var(nameOrPrefix: Name | string, rhs?: SafeExpr, _constant?: boolean): Name; +} + +// @public (undocumented) +interface CodeGenOptions { + // (undocumented) + es5?: boolean; + // (undocumented) + lines?: boolean; + // (undocumented) + ownProperties?: boolean; +} + +// @public (undocumented) +type CodeItem = Name | string | number | boolean | null; + +// @public (undocumented) +interface CodeKeywordDefinition extends _KeywordDef { + // (undocumented) + code: (cxt: KeywordCxt, ruleType?: string) => void; + // (undocumented) + trackErrors?: boolean; +} + +// @public (undocumented) +interface CodeOptions { + // (undocumented) + es5?: boolean; + // (undocumented) + esm?: boolean; + // (undocumented) + formats?: Code; + // (undocumented) + lines?: boolean; + // (undocumented) + optimize?: boolean | number; + // (undocumented) + process?: (code: string, schema?: SchemaEnv) => string; + // (undocumented) + regExp?: RegExpEngine; + // (undocumented) + source?: boolean; +} + +// @public (undocumented) +type CompileKeywordFunc = (schema: any, parentSchema: AnySchemaObject, it: SchemaObjCxt) => DataValidateFunction; + +// @public (undocumented) +interface CurrentOptions { + // (undocumented) + $comment?: true | ((comment: string, schemaPath?: string, rootSchema?: AnySchemaObject) => unknown); + // (undocumented) + $data?: boolean; + // (undocumented) + addUsedSchema?: boolean; + // (undocumented) + allErrors?: boolean; + // (undocumented) + allowDate?: boolean; + // (undocumented) + allowMatchingProperties?: boolean; + // (undocumented) + allowUnionTypes?: boolean; + // (undocumented) + code?: CodeOptions; + // (undocumented) + coerceTypes?: boolean | "array"; + // (undocumented) + defaultMeta?: string | AnySchemaObject; + // (undocumented) + discriminator?: boolean; + // (undocumented) + dynamicRef?: boolean; + // (undocumented) + formats?: { [Name in string]?: Format }; + // (undocumented) + inlineRefs?: boolean | number; + // (undocumented) + int32range?: boolean; + // (undocumented) + jtd?: boolean; + // (undocumented) + keywords?: Vocabulary; + // (undocumented) + loadSchema?: (uri: string) => Promise; + // (undocumented) + logger?: Logger | false; + // (undocumented) + loopEnum?: number; + // (undocumented) + loopRequired?: number; + // (undocumented) + messages?: boolean; + // (undocumented) + meta?: SchemaObject | boolean; + // (undocumented) + multipleOfPrecision?: number; + // (undocumented) + next?: boolean; + // (undocumented) + ownProperties?: boolean; + // (undocumented) + parseDate?: boolean; + // (undocumented) + passContext?: boolean; + // (undocumented) + removeAdditional?: boolean | "all" | "failing"; + // (undocumented) + schemaId?: "id" | "$id"; + // (undocumented) + schemas?: AnySchema[] | { [Key in string]?: AnySchema }; + // (undocumented) + specialNumbers?: "fast" | "null"; + // (undocumented) + strict?: boolean | "log"; + // (undocumented) + strictNumbers?: boolean | "log"; + // (undocumented) + strictRequired?: boolean | "log"; + // (undocumented) + strictSchema?: boolean | "log"; + // (undocumented) + strictTuples?: boolean | "log"; + // (undocumented) + strictTypes?: boolean | "log"; + // (undocumented) + timestamp?: "string" | "date"; + // (undocumented) + unevaluated?: boolean; + // (undocumented) + unicodeRegExp?: boolean; + // (undocumented) + uriResolver?: UriResolver; + // (undocumented) + useDefaults?: boolean | "empty"; + // (undocumented) + validateFormats?: boolean; + // (undocumented) + validateSchema?: boolean | "log"; + // (undocumented) + verbose?: boolean; +} + +// @public (undocumented) +interface DataValidateFunction { + // (undocumented) + (...args: Parameters): boolean | Promise; + // (undocumented) + errors?: Partial[]; +} + +// @public (undocumented) +interface DataValidationCxt { + // (undocumented) + dynamicAnchors: { [Ref in string]?: ValidateFunction }; + // (undocumented) + instancePath: string; + // (undocumented) + parentData: { [K in T]: any }; + // (undocumented) + parentDataProperty: T; + // (undocumented) + rootData: Record | any[]; +} + +// @public (undocumented) +interface DeprecatedOptions { + // @deprecated (undocumented) + ignoreKeywordsWithRef?: boolean; + // @deprecated (undocumented) + jsPropertySyntax?: boolean; + // @deprecated (undocumented) + unicode?: boolean; +} + +// @public +type EnumString = [T] extends [never] ? null : T extends string ? string extends T ? null : T : null; + +// @public (undocumented) +interface ErrorObject, S = unknown> { + // (undocumented) + data?: unknown; + // (undocumented) + instancePath: string; + // (undocumented) + keyword: K; + // (undocumented) + message?: string; + // (undocumented) + params: P; + // (undocumented) + parentSchema?: AnySchemaObject; + // (undocumented) + propertyName?: string; + // (undocumented) + schema?: S; + // (undocumented) + schemaPath: string; +} + +// @public (undocumented) +interface ErrorPaths { + // (undocumented) + instancePath?: Code; + // (undocumented) + parentSchema?: boolean; + // (undocumented) + schemaPath?: string; +} + +// @public (undocumented) +interface ErrorsTextOptions { + // (undocumented) + dataVar?: string; + // (undocumented) + separator?: string; +} + +// @public (undocumented) +interface Evaluated { + // (undocumented) + dynamicItems: boolean; + // (undocumented) + dynamicProps: boolean; + // (undocumented) + items?: EvaluatedItems; + // (undocumented) + props?: EvaluatedProperties; +} + +// @public (undocumented) +type EvaluatedItems = number | true; + +// @public (undocumented) +type EvaluatedProperties = { [K in string]?: true } | true; + +// @public (undocumented) +type Format = AddedFormat | string; + +// @public (undocumented) +type FormatCompare = (data1: T, data2: T) => number | undefined; + +// @public (undocumented) +interface FormatDefinition { + // (undocumented) + async?: false | undefined; + // (undocumented) + compare?: FormatCompare; + // (undocumented) + type?: T extends string ? "string" | undefined : "number"; + // (undocumented) + validate: FormatValidator | (T extends string ? string | RegExp : never); +} + +// @public (undocumented) +type FormatMode = "fast" | "full"; + +// @public (undocumented) +type FormatName = "date" | "time" | "date-time" | "iso-time" | "iso-date-time" | "duration" | "uri" | "uri-reference" | "uri-template" | "url" | "email" | "hostname" | "ipv4" | "ipv6" | "regex" | "uuid" | "json-pointer" | "json-pointer-uri-fragment" | "relative-json-pointer" | "byte" | "int32" | "int64" | "float" | "double" | "password" | "binary"; + +// @public (undocumented) +interface FormatOptions { + // (undocumented) + formats?: FormatName[]; + // (undocumented) + keywords?: boolean; + // (undocumented) + mode?: FormatMode; +} + +// @public (undocumented) +type FormatValidator = (data: T) => boolean; + +// @public (undocumented) +interface FormatsPlugin extends Plugin_2 { + // (undocumented) + get: (format: FormatName, mode?: FormatMode) => Format; +} + +// @public (undocumented) +type FormatsPluginOptions = FormatName[] | FormatOptions; + +// @public (undocumented) +interface FuncKeywordDefinition extends _KeywordDef { + // (undocumented) + async?: boolean; + // (undocumented) + compile?: CompileKeywordFunc; + // (undocumented) + errors?: boolean | "full"; + // (undocumented) + modifying?: boolean; + // (undocumented) + schema?: boolean; + // (undocumented) + valid?: boolean; + // (undocumented) + validate?: SchemaValidateFunction | DataValidateFunction; +} + +// @public (undocumented) +interface InstanceCodeOptions extends CodeOptions { + // (undocumented) + optimize: number; + // (undocumented) + regExp: RegExpEngine; +} + +// @public (undocumented) +type InstanceOptions = Options & RequiredInstanceOptions; + +// @public +type IsElements = false extends IsUnion ? [T] extends [readonly unknown[]] ? undefined extends T[0.5] ? false : true : false : false; + +// @public +type IsEmptyRecord = [T] extends [Record] ? [T] extends [never] ? false : true : false; + +// @public +type IsEnum = null extends EnumString ? false : true; + +// @public +type IsRecord = Union extends IsUnion ? null extends EnumString ? false : true : false; + +// @public (undocumented) +type IsUnion = IsUnion_; + +// @public +type IsUnion_ = false extends (T extends unknown ? ([U] extends [T] ? false : true) : never) ? false : true; + +// @public +type IsValues = false extends IsUnion ? TypeEquality : false; + +// @public (undocumented) +type JSONSchemaType = StrictNullChecksWrapper<"JSONSchemaType", UncheckedJSONSchemaType>; + +// @public (undocumented) +type JSONType = (typeof _jsonTypes)[number]; + +// @public (undocumented) +type JSONType$2 = IsPartial extends true ? T | undefined : T; + +// @public (undocumented) +type JTDDataDef> = +// ref +(S extends { + ref: string; +} ? D extends { [K in S["ref"]]: infer V } ? JTDDataDef : never : S extends { + type: NumberType; +} ? number : S extends { + type: "boolean"; +} ? boolean : S extends { + type: "string"; +} ? string : S extends { + type: "timestamp"; +} ? string | Date : S extends { + enum: readonly (infer E)[]; +} ? string extends E ? never : [E] extends [string] ? E : never : S extends { + elements: infer E; +} ? JTDDataDef[] : S extends { + properties: Record; + optionalProperties?: Record; + additionalProperties?: boolean; +} ? { -readonly [K in keyof S["properties"]]-?: JTDDataDef } & { -readonly [K in keyof S["optionalProperties"]]+?: JTDDataDef } & ([S["additionalProperties"]] extends [true] ? Record : unknown) : S extends { + properties?: Record; + optionalProperties: Record; + additionalProperties?: boolean; +} ? { -readonly [K in keyof S["properties"]]-?: JTDDataDef } & { -readonly [K in keyof S["optionalProperties"]]+?: JTDDataDef } & ([S["additionalProperties"]] extends [true] ? Record : unknown) : S extends { + values: infer V; +} ? Record> : S extends { + discriminator: infer M; + mapping: Record; +} ? [M] extends [string] ? { [K in keyof S["mapping"]]: JTDDataDef & { [KM in M]: K } }[keyof S["mapping"]] : never : unknown) | (S extends { + nullable: true; +} ? null : never); + +// @public (undocumented) +type JTDDataType = S extends { + definitions: Record; +} ? JTDDataDef : JTDDataDef>; + +// @public +type JTDSchemaType = Record> = ( +// refs - where null wasn't specified, must match exactly +(null extends EnumString ? never : ({ [K in keyof D]: [T] extends [D[K]] ? { + ref: K; + } : never }[keyof D] & { + nullable?: false; +}) | (null extends T ? { [K in keyof D]: [Exclude] extends [Exclude] ? { + ref: K; + } : never }[keyof D] & { + nullable: true; +} : never)) | (unknown extends T ? { + nullable?: boolean; +} : never) | ((true extends NullTypeEquality ? { + type: NumberType; +} : true extends NullTypeEquality ? { + type: "boolean"; +} : true extends NullTypeEquality ? { + type: StringType; +} : true extends NullTypeEquality ? { + type: "timestamp"; +} : true extends IsEnum> ? { + enum: EnumString>[]; +} : true extends IsElements> ? T extends readonly (infer E)[] ? { + elements: JTDSchemaType; +} : never : true extends IsEmptyRecord> ? { + properties: Record; + optionalProperties?: Record; +} | { + optionalProperties: Record; +} : true extends IsValues> ? T extends Record ? { + values: JTDSchemaType; +} : never : true extends IsRecord, false> ? ([RequiredKeys>] extends [never] ? { + properties?: Record; +} : { + properties: { [K in RequiredKeys]: JTDSchemaType }; +}) & ([OptionalKeys>] extends [never] ? { + optionalProperties?: Record; +} : { + optionalProperties: { [K in OptionalKeys]: JTDSchemaType, D> }; +}) & { + additionalProperties?: boolean; +} : true extends IsRecord, true> ? { [K in keyof Exclude]-?: Exclude[K] extends string ? { + discriminator: K; + mapping: { [M in Exclude[K]]: JTDSchemaType ? T : never, K>, D> }; + } : never }[keyof Exclude] : never) & (null extends T ? { + nullable: true; +} : { + nullable?: false; +}))) & { + metadata?: Record; + definitions?: { [K in keyof D]: JTDSchemaType }; +}; + +// @public +type JsonSchemaType = JSONSchema.Interface; + +// @public +type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public (undocumented) +class KeywordCxt implements KeywordErrorCxt { + // (undocumented) + readonly $data?: string | false; + // (undocumented) + $dataError(): void; + constructor(it: SchemaObjCxt, def: AddedKeywordDefinition, keyword: string); + // (undocumented) + readonly allErrors?: boolean; + // (undocumented) + block$data(valid: Name, codeBlock: () => void, $dataValid?: Code): void; + // (undocumented) + check$data(valid?: Name, $dataValid?: Code): void; + // (undocumented) + readonly data: Name; + // (undocumented) + readonly def: AddedKeywordDefinition; + // (undocumented) + error(append?: boolean, errorParams?: KeywordCxtParams, errorPaths?: ErrorPaths): void; + // (undocumented) + readonly errsCount?: Name; + // (undocumented) + fail$data(condition: Code): void; + // (undocumented) + fail(condition?: Code): void; + // (undocumented) + failResult(condition: Code, successAction?: () => void, failAction?: () => void): void; + // (undocumented) + readonly gen: CodeGen; + // (undocumented) + invalid$data(): Code; + // (undocumented) + readonly it: SchemaObjCxt; + // (undocumented) + readonly keyword: string; + // (undocumented) + mergeEvaluated(schemaCxt: SchemaCxt, toName?: typeof Name): void; + // (undocumented) + mergeValidEvaluated(schemaCxt: SchemaCxt, valid: Name): boolean | void; + // (undocumented) + ok(cond: Code | boolean): void; + // (undocumented) + params: KeywordCxtParams; + // (undocumented) + readonly parentSchema: AnySchemaObject; + // (undocumented) + pass(condition: Code, failAction?: () => void): void; + // (undocumented) + reset(): void; + // (undocumented) + result(condition: Code, successAction?: () => void, failAction?: () => void): void; + // (undocumented) + schema: any; + // (undocumented) + readonly schemaCode: Code | number | boolean; + // (undocumented) + readonly schemaType: JSONType[]; + // (undocumented) + readonly schemaValue: Code | number | boolean; + // (undocumented) + setParams(obj: KeywordCxtParams, assign?: true): void; + // (undocumented) + subschema(appl: SubschemaArgs, valid: Name): SchemaCxt; +} + +// @public (undocumented) +type KeywordCxtParams = { [P in string]?: Code | string | number }; + +// @public (undocumented) +type KeywordDefinition = CodeKeywordDefinition | FuncKeywordDefinition | MacroKeywordDefinition; + +// @public (undocumented) +interface KeywordErrorCxt { + // (undocumented) + $data?: string | false; + // (undocumented) + data: Name; + // (undocumented) + errsCount?: Name; + // (undocumented) + gen: CodeGen; + // (undocumented) + it: SchemaCxt; + // (undocumented) + keyword: string; + // (undocumented) + params: KeywordCxtParams; + // (undocumented) + parentSchema?: AnySchemaObject; + // (undocumented) + schema: any; + // (undocumented) + schemaCode: Code | number | boolean; + // (undocumented) + schemaType?: JSONType[]; + // (undocumented) + schemaValue: Code | number | boolean; +} + +// @public (undocumented) +interface KeywordErrorDefinition { + // (undocumented) + message: string | Code | ((cxt: KeywordErrorCxt) => string | Code); + // (undocumented) + params?: Code | ((cxt: KeywordErrorCxt) => Code); +} + +// @public (undocumented) +type Known = { + [key: string]: Known; +} | [Known, ...Known[]] | Known[] | number | string | boolean | null; + +// @public (undocumented) +type LocalRefs = { [Ref in string]?: AnySchemaObject }; + +// @public (undocumented) +interface Logger { + // (undocumented) + error(...args: unknown[]): unknown; + // (undocumented) + log(...args: unknown[]): unknown; + // (undocumented) + warn(...args: unknown[]): unknown; +} + +// @public (undocumented) +interface MacroKeywordDefinition extends FuncKeywordDefinition { + // (undocumented) + macro: MacroKeywordFunc; +} + +// @public (undocumented) +type MacroKeywordFunc = (schema: any, parentSchema: AnySchemaObject, it: SchemaCxt) => AnySchema; + +// @public (undocumented) +class MissingRefError extends Error { + constructor(resolver: UriResolver, baseId: string, ref: string, msg?: string); + // (undocumented) + readonly missingRef: string; + // (undocumented) + readonly missingSchema: string; +} + +// @public (undocumented) +class Name extends _CodeOrName { + constructor(s: string); + // (undocumented) + emptyStr(): boolean; + // (undocumented) + get names(): UsedNames; + // (undocumented) + readonly str: string; + // (undocumented) + toString(): string; +} + +// @public (undocumented) +interface NameGroup { + // (undocumented) + index: number; + // (undocumented) + prefix: string; +} + +// @public (undocumented) +interface NameValue { + // (undocumented) + code?: Code; + // (undocumented) + key?: unknown; + // (undocumented) + ref: ValueReference; +} + +// @public +type NullTypeEquality = TypeEquality; + +// @public (undocumented) +type Nullable = undefined extends T ? { + nullable: true; + const?: null; + enum?: readonly (T | null)[]; + default?: T | null; +} : { + nullable?: false; + const?: T; + enum?: readonly T[]; + default?: T; +}; + +// @public (undocumented) +interface NumberKeywords { + // (undocumented) + exclusiveMaximum?: number; + // (undocumented) + exclusiveMinimum?: number; + // (undocumented) + format?: string; + // (undocumented) + maximum?: number; + // (undocumented) + minimum?: number; + // (undocumented) + multipleOf?: number; +} + +// @public +type NumberType = "float32" | "float64" | "int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32"; + +// @public +type OptionalKeys = { [K in keyof T]-?: undefined extends T[K] ? K : never }[keyof T]; + +// @public (undocumented) +type Options = CurrentOptions & DeprecatedOptions; + +// @public (undocumented) +interface Plugin_2 { + // (undocumented) + (ajv: Ajv$2, options?: Opts): Ajv$2; + // (undocumented) + [prop: string]: any; +} + +// @public (undocumented) +interface RegExpEngine { + // (undocumented) + (pattern: string, u: string): RegExpLike; + // (undocumented) + code: string; +} + +// @public (undocumented) +interface RegExpLike { + // (undocumented) + test: (s: string) => boolean; +} + +// @public (undocumented) +type RequiredInstanceOptions = { [K in "strictSchema" | "strictNumbers" | "strictTypes" | "strictTuples" | "strictRequired" | "inlineRefs" | "loopRequired" | "loopEnum" | "meta" | "messages" | "schemaId" | "addUsedSchema" | "validateSchema" | "validateFormats" | "int32range" | "unicodeRegExp" | "uriResolver"]: NonNullable } & { + code: InstanceCodeOptions; +}; + +// @public +type RequiredKeys = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]; + +// @public (undocumented) +interface Rule { + // (undocumented) + definition: AddedKeywordDefinition; + // (undocumented) + keyword: string; +} + +// @public (undocumented) +interface RuleGroup { + // (undocumented) + rules: Rule[]; + // (undocumented) + type?: JSONType; +} + +// @public (undocumented) +type SafeExpr = Code | number | boolean | null; + +// @public (undocumented) +type Schema = SchemaObject | boolean; + +// @public (undocumented) +interface SchemaCxt { + // (undocumented) + readonly allErrors?: boolean; + // (undocumented) + baseId: string; + // (undocumented) + readonly compositeRule?: boolean; + // (undocumented) + readonly createErrors?: boolean; + // (undocumented) + readonly data: Name; + // (undocumented) + readonly dataLevel: number; + // (undocumented) + readonly dataNames: Name[]; + // (undocumented) + readonly dataPathArr: (Code | number)[]; + // (undocumented) + dataTypes: JSONType[]; + // (undocumented) + definedProperties: Set; + // (undocumented) + readonly errorPath: Code; + // (undocumented) + readonly errSchemaPath: string; + // (undocumented) + evaluated?: Name; + // (undocumented) + readonly gen: CodeGen; + // (undocumented) + items?: EvaluatedItems | Name; + // (undocumented) + jtdDiscriminator?: string; + // (undocumented) + jtdMetadata?: boolean; + // (undocumented) + readonly opts: InstanceOptions; + // (undocumented) + readonly parentData: Name; + // (undocumented) + readonly parentDataProperty: Code | number; + // (undocumented) + readonly propertyName?: Name; + // (undocumented) + props?: EvaluatedProperties | Name; + // (undocumented) + readonly rootId: string; + // (undocumented) + readonly schema: AnySchema; + // (undocumented) + readonly schemaEnv: SchemaEnv; + // (undocumented) + readonly schemaPath: Code; + // (undocumented) + readonly self: Ajv$2; + // (undocumented) + readonly topSchemaRef: Code; + // (undocumented) + readonly validateName: Name; + // (undocumented) + readonly ValidationError?: Name; +} + +// @public (undocumented) +class SchemaEnv implements SchemaEnvArgs { + // (undocumented) + readonly $async?: boolean; + constructor(env: SchemaEnvArgs); + // (undocumented) + baseId: string; + // (undocumented) + readonly dynamicAnchors: { [Ref in string]?: true }; + // (undocumented) + localRefs?: LocalRefs; + // (undocumented) + readonly meta?: boolean; + // (undocumented) + parse?: (data: string) => unknown; + // (undocumented) + parseName?: ValueScopeName; + // (undocumented) + readonly refs: SchemaRefs; + // (undocumented) + readonly root: SchemaEnv; + // (undocumented) + readonly schema: AnySchema; + // (undocumented) + readonly schemaId?: "$id" | "id"; + // (undocumented) + schemaPath?: string; + // (undocumented) + serialize?: (data: unknown) => string; + // (undocumented) + serializeName?: ValueScopeName; + // (undocumented) + validate?: AnyValidateFunction; + // (undocumented) + validateName?: ValueScopeName; +} + +// @public (undocumented) +interface SchemaEnvArgs { + // (undocumented) + readonly baseId?: string; + // (undocumented) + readonly localRefs?: LocalRefs; + // (undocumented) + readonly meta?: boolean; + // (undocumented) + readonly root?: SchemaEnv; + // (undocumented) + readonly schema: AnySchema; + // (undocumented) + readonly schemaId?: "$id" | "id"; + // (undocumented) + readonly schemaPath?: string; +} + +// @public (undocumented) +interface SchemaObjCxt extends SchemaCxt { + // (undocumented) + readonly schema: AnySchemaObject; +} + +// @public (undocumented) +interface SchemaObject extends _SchemaObject { + // (undocumented) + $async?: false; + // (undocumented) + $id?: string; + // (undocumented) + $schema?: string; + // (undocumented) + [x: string]: any; + // (undocumented) + id?: string; +} + +// @public (undocumented) +type SchemaRefs = { [Ref in string]?: SchemaEnv | AnySchema }; + +// @public (undocumented) +interface SchemaValidateFunction { + // (undocumented) + (schema: any, data: any, parentSchema?: AnySchemaObject, dataCxt?: DataValidationCxt): boolean | Promise; + // (undocumented) + errors?: Partial[]; +} + +// @public (undocumented) +class Scope { + constructor(input?: ScopeOptions); + // (undocumented) + name(prefix: string): Name; + // (undocumented) + protected readonly _names: { [Prefix in string]?: NameGroup }; + // (undocumented) + protected _newName(prefix: string): string; + // (undocumented) + protected readonly _parent?: Scope; + // (undocumented) + protected readonly _prefixes?: Set; + // (undocumented) + toName(nameOrPrefix: Name | string): Name; +} + +// @public (undocumented) +interface ScopeOptions { + // (undocumented) + parent?: Scope; + // (undocumented) + prefixes?: Set; +} + +// @public (undocumented) +interface ScopePath { + // (undocumented) + itemIndex: number; + // (undocumented) + property: string; +} + +// @public (undocumented) +type ScopeStore = Record; + +// @public (undocumented) +type ScopeValueSets = { [Prefix in string]?: Set }; + +// @public (undocumented) +type ScopeValues = { [Prefix in string]?: Map }; + +// @public +type SomeJTDSchemaType = ( +// ref + { + ref: string; +} | { + type: NumberType | StringType | "boolean"; +} | { + enum: string[]; +} | { + elements: SomeJTDSchemaType; +} | { + values: SomeJTDSchemaType; +} | { + properties: Record; + optionalProperties?: Record; + additionalProperties?: boolean; +} | { + properties?: Record; + optionalProperties: Record; + additionalProperties?: boolean; +} | { + discriminator: string; + mapping: Record; +} | {}) & { + nullable?: boolean; + metadata?: Record; + definitions?: Record; +}; + +// @public (undocumented) +interface SourceCode { + // (undocumented) + evaluated?: Code; + // (undocumented) + scopeValues: ScopeValueSets; + // (undocumented) + validateCode: string; + // (undocumented) + validateName: ValueScopeName; +} + +// @public (undocumented) +type StrictNullChecksWrapper = undefined extends null ? `strictNullChecks must be true in tsconfig to use ${Name}` : Type; + +// @public (undocumented) +interface StringKeywords { + // (undocumented) + format?: string; + // (undocumented) + maxLength?: number; + // (undocumented) + minLength?: number; + // (undocumented) + pattern?: string; +} + +// @public +type StringType = "string" | "timestamp"; + +// @public (undocumented) +type SubschemaArgs = Partial<{ + keyword: string; + schemaProp: string | number; + schema: AnySchema; + schemaPath: Code; + errSchemaPath: string; + topSchemaRef: Code; + data: Name | Code; + dataProp: Code | string | number; + dataTypes: JSONType[]; + definedProperties: Set; + propertyName: Name; + dataPropType: Type; + jtdDiscriminator: string; + jtdMetadata: boolean; + compositeRule: true; + createErrors: boolean; + allErrors: boolean; +}>; + +// @public (undocumented) +enum Type { + // (undocumented) + Num = 0, + // (undocumented) + Str = 1, +} + +// @public +type TypeEquality = [T] extends [E] ? ([E] extends [T] ? true : false) : false; + +// @public (undocumented) +type UncheckedJSONSchemaType = ( +// these two unions allow arbitrary unions of types + { + anyOf: readonly UncheckedJSONSchemaType[]; +} | { + oneOf: readonly UncheckedJSONSchemaType[]; +} | ({ + type: readonly (T extends number ? JSONType$2<"number" | "integer", IsPartial> : T extends string ? JSONType$2<"string", IsPartial> : T extends boolean ? JSONType$2<"boolean", IsPartial> : never)[]; +} & UnionToIntersection) | ((T extends number ? { + type: JSONType$2<"number" | "integer", IsPartial>; +} & NumberKeywords : T extends string ? { + type: JSONType$2<"string", IsPartial>; +} & StringKeywords : T extends boolean ? { + type: JSONType$2<"boolean", IsPartial>; +} : T extends readonly [any, ...any[]] ? { + type: JSONType$2<"array", IsPartial>; + items: { readonly [K in keyof T]-?: UncheckedJSONSchemaType & Nullable } & { + length: T["length"]; + }; + minItems: T["length"]; +} & ({ + maxItems: T["length"]; +} | { + additionalItems: false; +}) : T extends readonly any[] ? { + type: JSONType$2<"array", IsPartial>; + items: UncheckedJSONSchemaType; + contains?: UncheckedPartialSchema; + minItems?: number; + maxItems?: number; + minContains?: number; + maxContains?: number; + uniqueItems?: true; + additionalItems?: never; +} : T extends Record ? { + type: JSONType$2<"object", IsPartial>; + additionalProperties?: boolean | UncheckedJSONSchemaType; + unevaluatedProperties?: boolean | UncheckedJSONSchemaType; + properties?: IsPartial extends true ? Partial> : UncheckedPropertiesSchema; + patternProperties?: Record>; + propertyNames?: Omit, "type"> & { + type?: "string"; + }; + dependencies?: { [K in keyof T]?: readonly (keyof T)[] | UncheckedPartialSchema }; + dependentRequired?: { [K in keyof T]?: readonly (keyof T)[] }; + dependentSchemas?: { [K in keyof T]?: UncheckedPartialSchema }; + minProperties?: number; + maxProperties?: number; +} & (IsPartial extends true ? { + required: readonly (keyof T)[]; +} : [UncheckedRequiredMembers] extends [never] ? { + required?: readonly UncheckedRequiredMembers[]; +} : { + required: readonly UncheckedRequiredMembers[]; +}) : T extends null ? { + type: JSONType$2<"null", IsPartial>; + nullable: true; +} : never) & { + allOf?: readonly UncheckedPartialSchema[]; + anyOf?: readonly UncheckedPartialSchema[]; + oneOf?: readonly UncheckedPartialSchema[]; + if?: UncheckedPartialSchema; + then?: UncheckedPartialSchema; + else?: UncheckedPartialSchema; + not?: UncheckedPartialSchema; +})) & { + [keyword: string]: any; + $id?: string; + $ref?: string; + $defs?: Record>; + definitions?: Record>; +}; + +// @public (undocumented) +type UncheckedPartialSchema = Partial>; + +// @public (undocumented) +type UncheckedPropertiesSchema = { [K in keyof T]-?: (UncheckedJSONSchemaType & Nullable) | { + $ref: string; + } }; + +// @public (undocumented) +type UncheckedRequiredMembers = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]; + +// @public (undocumented) +type UnionToIntersection = (U extends any ? (_: U) => void : never) extends ((_: infer I) => void) ? I : never; + +// @public (undocumented) +interface UriResolver { + // (undocumented) + parse(uri: string): URIComponent; + // (undocumented) + resolve(base: string, path: string): string; + // (undocumented) + serialize(component: URIComponent): string; +} + +// @public (undocumented) +type UsedNames = Record; + +// @public (undocumented) +type UsedScopeValues = { [Prefix in string]?: Map }; + +// @public (undocumented) +enum UsedValueState { + // (undocumented) + Completed = 1, + // (undocumented) + Started = 0, +} + +// @public (undocumented) +interface VSOptions extends ValueScopeOptions { + // (undocumented) + _n: Code; +} + +// @public (undocumented) +interface ValidateFunction { + // (undocumented) + (this: Ajv$2 | any, data: any, dataCxt?: DataValidationCxt): data is T; + // (undocumented) + errors?: null | ErrorObject[]; + // (undocumented) + evaluated?: Evaluated; + // (undocumented) + schema: AnySchema; + // (undocumented) + schemaEnv: SchemaEnv; + // (undocumented) + source?: SourceCode; +} + +// @public (undocumented) +class ValidationError extends Error { + constructor(errors: Partial[]); + // (undocumented) + readonly ajv: true; + // (undocumented) + readonly errors: Partial[]; + // (undocumented) + readonly validation: true; +} + +// @public (undocumented) +interface ValidationRules { + // (undocumented) + all: { [Key in string]?: boolean | Rule }; + // (undocumented) + keywords: { [Key in string]?: boolean }; + // (undocumented) + post: RuleGroup; + // (undocumented) + rules: RuleGroup[]; + // (undocumented) + types: ValidationTypes; +} + +// @public (undocumented) +type ValidationTypes = { [K in JSONType]: boolean | RuleGroup | undefined }; + +// @public (undocumented) +type ValueReference = unknown; + +// @public (undocumented) +class ValueScope extends Scope { + constructor(opts: ValueScopeOptions); + // (undocumented) + get(): ScopeStore; + // (undocumented) + getValue(prefix: string, keyOrRef: unknown): ValueScopeName | undefined; + // (undocumented) + name(prefix: string): ValueScopeName; + // (undocumented) + readonly opts: VSOptions; + // (undocumented) + protected readonly _scope: ScopeStore; + // (undocumented) + scopeCode(values?: ScopeValues | ScopeValueSets, usedValues?: UsedScopeValues, getCode?: (n: ValueScopeName) => Code | undefined): Code; + // (undocumented) + scopeRefs(scopeName: Name, values?: ScopeValues | ScopeValueSets): Code; + // (undocumented) + value(nameOrPrefix: ValueScopeName | string, value: NameValue): ValueScopeName; + // (undocumented) + protected readonly _values: ScopeValues; +} + +// @public (undocumented) +class ValueScopeName extends Name { + constructor(prefix: string, nameStr: string); + // (undocumented) + readonly prefix: string; + // (undocumented) + scopePath?: Code; + // (undocumented) + setValue(value: NameValue, input: ScopePath): void; + // (undocumented) + value?: NameValue; +} + +// @public (undocumented) +interface ValueScopeOptions extends ScopeOptions { + // (undocumented) + es5?: boolean; + // (undocumented) + lines?: boolean; + // (undocumented) + scope: ScopeStore; +} + +// @public (undocumented) +type Vocabulary = (KeywordDefinition | string)[]; + +// @public (undocumented) +class _Code extends _CodeOrName { + constructor(code: string | readonly CodeItem[]); + // (undocumented) + emptyStr(): boolean; + // (undocumented) + readonly _items: readonly CodeItem[]; + // (undocumented) + get names(): UsedNames; + // (undocumented) + get str(): string; + // (undocumented) + toString(): string; +} + +// @public (undocumented) +abstract class _CodeOrName { + // (undocumented) + abstract emptyStr(): boolean; + // (undocumented) + abstract readonly names: UsedNames; + // (undocumented) + abstract readonly str: string; + // (undocumented) + abstract toString(): string; +} + +// @public (undocumented) +interface _KeywordDef { + // (undocumented) + $data?: boolean; + // (undocumented) + $dataError?: KeywordErrorDefinition; + // (undocumented) + allowUndefined?: boolean; + // (undocumented) + before?: string; + // (undocumented) + dependencies?: string[]; + // (undocumented) + error?: KeywordErrorDefinition; + // (undocumented) + implements?: string[]; + // (undocumented) + keyword: string | string[]; + // (undocumented) + metaSchema?: AnySchemaObject; + // (undocumented) + post?: boolean; + // (undocumented) + schemaType?: JSONType | JSONType[]; + // (undocumented) + type?: JSONType | JSONType[]; + // (undocumented) + validateSchema?: AnyValidateFunction; +} + +// @public (undocumented) +interface _SchemaObject { + // (undocumented) + $id?: string; + // (undocumented) + $schema?: string; + // (undocumented) + [x: string]: any; + // (undocumented) + id?: string; +} + +// @public (undocumented) +const _jsonTypes: readonly ["string", "number", "integer", "boolean", "null", "object", "array"]; + +// @public +export const addFormats: typeof formatsPlugin.default; + +// @public (undocumented) +const formatsPlugin: FormatsPlugin; + +// @public +interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/server/etc/server.validators-cf-worker.api.md b/packages/server/etc/server.validators-cf-worker.api.md new file mode 100644 index 0000000000..5f380d55b4 --- /dev/null +++ b/packages/server/etc/server.validators-cf-worker.api.md @@ -0,0 +1,44 @@ +## API Report File for "@modelcontextprotocol/server" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JSONSchema } from 'json-schema-typed'; + +// @public +export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { + constructor(options?: { + shortcircuit?: boolean; + draft?: CfWorkerSchemaDraft; + }); + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// @public +export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; + +// @public +type JsonSchemaType = JSONSchema.Interface; + +// @public +type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +// @public +type JsonSchemaValidatorResult = { + valid: true; + data: T; + errorMessage: undefined; +} | { + valid: false; + data: undefined; + errorMessage: string; +}; + +// @public +interface jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ffd38d3dd..7a77a804c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: '@eslint/js': specifier: catalog:devTools version: 9.39.4 + '@microsoft/api-extractor': + specifier: 7.58.7 + version: 7.58.7(@types/node@24.12.0) '@modelcontextprotocol/client': specifier: workspace:^ version: link:packages/client @@ -2111,6 +2114,19 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@microsoft/api-extractor-model@7.33.8': + resolution: {integrity: sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==} + + '@microsoft/api-extractor@7.58.7': + resolution: {integrity: sha512-yK6OycD46gIzLRpj6ueVUWPk1ACSpkN1LBo05gY1qPTylbWyUCanXfH7+VgkI5LJrJoRSQR5F04XuCffCXLOBw==} + hasBin: true + + '@microsoft/tsdoc-config@0.18.1': + resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@modelcontextprotocol/conformance@0.2.0-alpha.3': resolution: {integrity: sha512-YjdEKaKWswkJtRl0G3RmZCfljkAct3je834sqGHgasGeU2eUp7sb+6sJL0uNEaAY3XXWYumN/mjr6aPZbnbJMA==} hasBin: true @@ -2562,6 +2578,36 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@rushstack/node-core-library@5.23.1': + resolution: {integrity: sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/problem-matcher@0.2.1': + resolution: {integrity: sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/rig-package@0.7.3': + resolution: {integrity: sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==} + + '@rushstack/terminal@0.24.0': + resolution: {integrity: sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@5.3.9': + resolution: {integrity: sha512-GIHqU+sRGQ3LGWAZu1O+9Yh++qwtyNIIGuNbcWHJjBTm2qRez0cwINUHZ+pQLR8UuzZDcMajrDaNbUYoaL/XtQ==} + '@shikijs/engine-oniguruma@3.23.0': resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} @@ -2593,6 +2639,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -2929,6 +2978,14 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -3373,6 +3430,10 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3778,6 +3839,10 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -3926,6 +3991,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + import-without-cache@0.2.5: resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} engines: {node: '>=20.19.0'} @@ -4102,6 +4171,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} @@ -4150,6 +4222,9 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4306,6 +4381,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + minimatch@10.2.3: + resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} + engines: {node: 18 || 20 || >=22} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -4829,6 +4908,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} @@ -4857,6 +4940,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -4908,6 +4995,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -5108,6 +5199,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -6001,6 +6096,41 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@microsoft/api-extractor-model@7.33.8(@types/node@24.12.0)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@rushstack/node-core-library': 5.23.1(@types/node@24.12.0) + transitivePeerDependencies: + - '@types/node' + + '@microsoft/api-extractor@7.58.7(@types/node@24.12.0)': + dependencies: + '@microsoft/api-extractor-model': 7.33.8(@types/node@24.12.0) + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@rushstack/node-core-library': 5.23.1(@types/node@24.12.0) + '@rushstack/rig-package': 0.7.3 + '@rushstack/terminal': 0.24.0(@types/node@24.12.0) + '@rushstack/ts-command-line': 5.3.9(@types/node@24.12.0) + diff: 8.0.4 + minimatch: 10.2.3 + resolve: 1.22.11 + semver: 7.7.4 + source-map: 0.6.1 + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/tsdoc-config@0.18.1': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.11 + + '@microsoft/tsdoc@0.16.0': {} + '@modelcontextprotocol/conformance@0.2.0-alpha.3(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) @@ -6334,6 +6464,45 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@rushstack/node-core-library@5.23.1(@types/node@24.12.0)': + dependencies: + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + ajv-formats: 3.0.1(ajv@8.18.0) + fs-extra: 11.3.5 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.11 + semver: 7.7.4 + optionalDependencies: + '@types/node': 24.12.0 + + '@rushstack/problem-matcher@0.2.1(@types/node@24.12.0)': + optionalDependencies: + '@types/node': 24.12.0 + + '@rushstack/rig-package@0.7.3': + dependencies: + jju: 1.4.0 + resolve: 1.22.11 + + '@rushstack/terminal@0.24.0(@types/node@24.12.0)': + dependencies: + '@rushstack/node-core-library': 5.23.1(@types/node@24.12.0) + '@rushstack/problem-matcher': 0.2.1(@types/node@24.12.0) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 24.12.0 + + '@rushstack/ts-command-line@5.3.9(@types/node@24.12.0)': + dependencies: + '@rushstack/terminal': 0.24.0(@types/node@24.12.0) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + '@shikijs/engine-oniguruma@3.23.0': dependencies: '@shikijs/types': 3.23.0 @@ -6371,6 +6540,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/argparse@1.0.38': {} + '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 24.12.0 @@ -6717,6 +6888,10 @@ snapshots: acorn@8.16.0: {} + ajv-draft-04@1.0.0(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -7118,6 +7293,8 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + diff@8.0.4: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -7703,6 +7880,12 @@ snapshots: fs-constants@1.0.0: {} + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -7853,6 +8036,8 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-lazy@4.0.0: {} + import-without-cache@0.2.5: {} imurmurhash@0.1.4: {} @@ -8015,6 +8200,8 @@ snapshots: isexe@2.0.0: {} + jju@1.4.0: {} + jose@6.2.2: {} js-yaml@3.14.2: @@ -8057,6 +8244,12 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -8195,6 +8388,10 @@ snapshots: - bufferutil - utf-8-validate + minimatch@10.2.3: + dependencies: + brace-expansion: 5.0.5 + minimatch@10.2.4: dependencies: brace-expansion: 5.0.5 @@ -8807,6 +9004,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.6.1: {} + spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -8829,6 +9028,8 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-argv@0.3.2: {} + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -8896,6 +9097,10 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} tapable@2.3.2: {} @@ -9107,6 +9312,8 @@ snapshots: universalify@0.1.2: {} + universalify@2.0.1: {} + unpipe@1.0.0: {} unrs-resolver@1.11.1: diff --git a/scripts/generate-api-reports.ts b/scripts/generate-api-reports.ts new file mode 100644 index 0000000000..ca45a46458 --- /dev/null +++ b/scripts/generate-api-reports.ts @@ -0,0 +1,683 @@ +/** + * Generates (or checks) the committed API reports for every public package. + * + * Each export-map entry of each publishable package gets an API Extractor + * report (`packages//etc/.api.md`) describing its complete public + * type surface. The committed reports are the baseline: `pnpm api-report:check` + * fails when the built surface differs from the committed report, which makes + * every public-surface change a deliberate act (regenerate with + * `pnpm api-report`, commit the diff, and have it reviewed). + * + * The PACKAGES manifest below is cross-checked against the actual package + * export maps before anything runs: a public package or `types` target that + * is neither listed nor explicitly exempted fails the script, so the gate + * cannot silently lose coverage when the export maps grow. + * + * Mechanics: the packages build with tsdown, which emits rolled-up `.d.mts` + * declaration bundles per entry point. Each entry's declarations are mirrored + * into a scratch folder (`.api-extractor-tmp/`, gitignored) so the run can + * host ambient fixups and a scoped tsconfig without polluting dist/, and API + * Extractor runs against the mirrored `.d.mts` entry directly. + * + * Notes on coverage: + * - `@modelcontextprotocol/core` is private and bundled into the client and + * server dists, so its surface is reported THROUGH those packages' reports + * rather than separately. + * - The `./_shims` entries are runtime-conditional; every distinct `types` + * target gets its own report (node, workerd, and — for the client — browser). + * - The codemod package is exempt entirely: it is migration tooling — its + * `mcp-codemod` bin is the contract (covered by the codemod CLI tests and + * the export-map topology pins), and its library surface carries no + * stability expectations for external consumers. + * + * A failing check does not mean a change is wrong — it means it is + * surface-visible. To accept it: run `pnpm api-report`, review the report + * diff like source, and commit it together with the change (plus a + * changeset if consumer-facing). + * + * Usage: + * pnpm api-report # build + regenerate the committed reports + * pnpm api-report:check # build + fail on any difference (CI) + */ +import { Extractor, ExtractorConfig, ExtractorLogLevel } from '@microsoft/api-extractor'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const checkMode = process.argv.includes('--check'); + +interface EntrySpec { + /** Entry declaration rollup, relative to the package's dist/ folder. */ + dist: string; + /** Report file name (api-extractor appends `.api.md`). */ + report: string; +} + +interface PackageSpec { + /** Package folder relative to the repo root. */ + dir: string; + entries: EntrySpec[]; +} + +const PACKAGES: PackageSpec[] = [ + { + dir: 'packages/client', + entries: [ + { dist: 'index.d.mts', report: 'client' }, + { dist: 'stdio.d.mts', report: 'client.stdio' }, + { dist: 'validators/ajv.d.mts', report: 'client.validators-ajv' }, + { dist: 'validators/cfWorker.d.mts', report: 'client.validators-cf-worker' }, + { dist: 'shimsNode.d.mts', report: 'client.shims' }, + { dist: 'shimsWorkerd.d.mts', report: 'client.shims-workerd' }, + { dist: 'shimsBrowser.d.mts', report: 'client.shims-browser' } + ] + }, + { + dir: 'packages/server', + entries: [ + { dist: 'index.d.mts', report: 'server' }, + { dist: 'stdio.d.mts', report: 'server.stdio' }, + { dist: 'validators/ajv.d.mts', report: 'server.validators-ajv' }, + { dist: 'validators/cfWorker.d.mts', report: 'server.validators-cf-worker' }, + { dist: 'shimsNode.d.mts', report: 'server.shims' }, + { dist: 'shimsWorkerd.d.mts', report: 'server.shims-workerd' } + ] + }, + { + dir: 'packages/server-legacy', + entries: [ + { dist: 'index.d.mts', report: 'server-legacy' }, + { dist: 'sse/index.d.mts', report: 'server-legacy.sse' }, + { dist: 'auth/index.d.mts', report: 'server-legacy.auth' } + ] + }, + { dir: 'packages/middleware/express', entries: [{ dist: 'index.d.mts', report: 'express' }] }, + { dir: 'packages/middleware/fastify', entries: [{ dist: 'index.d.mts', report: 'fastify' }] }, + { dir: 'packages/middleware/hono', entries: [{ dist: 'index.d.mts', report: 'hono' }] }, + { dir: 'packages/middleware/node', entries: [{ dist: 'index.d.mts', report: 'node' }] } +]; + +/** Public packages deliberately excluded from API reports, with the reason on record. */ +const EXEMPT_PACKAGES = new Map([ + ['packages/codemod', 'migration tooling: the mcp-codemod bin is the contract (codemod CLI tests + export-map topology pins)'] +]); + +/** Collect every `types` target reachable through an export-map value (handles nested conditions). */ +function collectTypesTargets(node: unknown, out: Set): void { + if (typeof node !== 'object' || node === null) { + return; + } + for (const [key, value] of Object.entries(node)) { + if (key === 'types' && typeof value === 'string') { + out.add(value); + } else { + collectTypesTargets(value, out); + } + } +} + +/** Find every package manifest under packages/ (top-most manifest wins; scratch/build dirs skipped). */ +function discoverPackageDirs(dir: string, found: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) { + continue; + } + const sub = path.join(dir, entry.name); + if (fs.existsSync(path.join(sub, 'package.json'))) { + found.push(sub); + } else { + discoverPackageDirs(sub, found); + } + } + return found; +} + +/** + * Fails when PACKAGES drifts from reality: a public package missing from the + * manifest (and not exempted), an export-map `types` target with no report + * entry, a report entry no export targets, or a private/exempt package still + * carrying committed reports. + */ +function verifyManifestCoverage(): void { + const problems: string[] = []; + const byDir = new Map(PACKAGES.map(pkg => [pkg.dir, pkg])); + + for (const abs of discoverPackageDirs(path.join(repoRoot, 'packages'))) { + const rel = path.relative(repoRoot, abs).split(path.sep).join('/'); + const manifest = JSON.parse(fs.readFileSync(path.join(abs, 'package.json'), 'utf8')) as { + private?: boolean; + exports?: Record; + }; + const pkg = byDir.get(rel); + + if (manifest.private === true || EXEMPT_PACKAGES.has(rel)) { + if (pkg) { + problems.push(`${rel} is ${manifest.private ? 'private' : 'exempt'} but listed in PACKAGES`); + } + const etcDir = path.join(abs, 'etc'); + if (fs.existsSync(etcDir)) { + for (const file of fs.readdirSync(etcDir)) { + if (file.endsWith('.api.md')) { + problems.push(`${rel}/etc/${file} exists but the package is not reported — remove it`); + } + } + } + continue; + } + if (!pkg) { + problems.push( + `${rel} is a public package with no PACKAGES entry — add its export entries (or an explicit exemption with a reason)` + ); + continue; + } + + const targets = new Set(); + collectTypesTargets(manifest.exports ?? {}, targets); + const targetDists = new Set([...targets].map(target => target.replace(/^\.\/dist\//, ''))); + const listedDists = new Set(pkg.entries.map(entry => entry.dist)); + for (const dist of targetDists) { + if (!listedDists.has(dist)) { + problems.push(`${rel}: exports types target ./dist/${dist} has no report entry in PACKAGES`); + } + } + for (const dist of listedDists) { + if (!targetDists.has(dist)) { + problems.push(`${rel}: report entry ${dist} matches no exports types target`); + } + } + } + + for (const pkg of PACKAGES) { + if (!fs.existsSync(path.join(repoRoot, pkg.dir, 'package.json'))) { + problems.push(`${pkg.dir} is listed in PACKAGES but has no package.json`); + } + } + + if (problems.length > 0) { + throw new Error(`PACKAGES is out of sync with the package export maps:\n ${problems.join('\n ')}`); + } +} + +/** Recursively copy every .d.mts file under distDir into mirrorDir. */ +function mirrorDeclarations(distDir: string, mirrorDir: string): number { + let copied = 0; + for (const entry of fs.readdirSync(distDir, { withFileTypes: true })) { + const from = path.join(distDir, entry.name); + if (entry.isDirectory()) { + copied += mirrorDeclarations(from, path.join(mirrorDir, entry.name)); + } else if (entry.name.endsWith('.d.mts')) { + fs.mkdirSync(mirrorDir, { recursive: true }); + fs.copyFileSync(from, path.join(mirrorDir, entry.name)); + copied += 1; + } + } + return copied; +} + +type EntryStatus = 'ok' | 'updated' | 'changed' | 'missing'; + +function runEntry(pkg: PackageSpec, entry: EntrySpec): EntryStatus { + const pkgDir = path.join(repoRoot, pkg.dir); + const distDir = path.join(pkgDir, 'dist'); + const distEntry = path.join(distDir, entry.dist); + if (!fs.existsSync(distEntry)) { + throw new Error(`Missing build output ${distEntry} — run the package builds first (pnpm api-report does this).`); + } + + const tmpDir = path.join(pkgDir, '.api-extractor-tmp', entry.report); + fs.rmSync(tmpDir, { recursive: true, force: true }); + mirrorDeclarations(distDir, tmpDir); + fs.mkdirSync(path.join(pkgDir, 'etc'), { recursive: true }); + + // The tsdown-bundled ajv declarations inline ajv's types, which reference + // uri-js's URIComponent without importing it. The dangling reference is + // harmless for consumers (skipLibCheck) but crashes API Extractor's symbol + // walker, so resolve it with an ambient declaration in the scratch mirror. + // (Injected for every entry: it is ambient-only and never exported, so it + // cannot appear in a report.) + fs.writeFileSync(path.join(tmpDir, '_ambient-fixups.d.ts'), 'type URIComponent = unknown;\n'); + + const packageJsonFullPath = path.join(pkgDir, 'package.json'); + const extractorConfig = ExtractorConfig.prepare({ + configObject: { + projectFolder: tmpDir, + // API Extractor consumes the mirrored .d.mts entry directly + // (supported since 7.36.2); the rollups' relative .mjs chunk + // imports resolve to the sibling mirrored .d.mts files. + mainEntryPointFilePath: path.join(tmpDir, entry.dist), + newlineKind: 'lf', + compiler: { + overrideTsconfig: { + compilerOptions: { + module: 'esnext', + moduleResolution: 'bundler', + target: 'es2022', + lib: ['es2023', 'dom'], + types: ['node'], + skipLibCheck: true + }, + include: ['**/*.d.ts', '**/*.d.mts'] + } + }, + apiReport: { + enabled: true, + reportFileName: entry.report, + // The report is produced into the scratch folder and compared / + // committed by this script (after normalization), never written + // to etc/ by API Extractor directly. + reportFolder: path.join(tmpDir, 'report'), + reportTempFolder: path.join(tmpDir, 'report'), + includeForgottenExports: true + }, + docModel: { enabled: false }, + dtsRollup: { enabled: false }, + tsdocMetadata: { enabled: false }, + messages: { + extractorMessageReporting: { + 'ae-missing-release-tag': { logLevel: ExtractorLogLevel.None }, + // Not added to the report file: the warning text embeds + // declaration-bundle chunk file names and line numbers, + // which vary with tsdown's chunking and would make the + // committed reports nondeterministic. The forgotten symbols + // themselves still appear in the report body. + 'ae-forgotten-export': { logLevel: ExtractorLogLevel.None, addToApiReportFile: false }, + 'ae-unresolved-link': { logLevel: ExtractorLogLevel.None } + }, + tsdocMessageReporting: { + default: { logLevel: ExtractorLogLevel.None } + } + } + }, + configObjectFullPath: path.join(tmpDir, 'api-extractor.json'), + packageJsonFullPath + }); + + const result = Extractor.invoke(extractorConfig, { + localBuild: true, + showVerboseMessages: false + }); + if (!result.succeeded) { + throw new Error('API Extractor reported errors'); + } + + const raw = fs.readFileSync(path.join(tmpDir, 'report', `${entry.report}.api.md`), 'utf8'); + const produced = normalizeReport(raw, `${pkg.dir} → ${entry.report}.api.md`); + const committedPath = path.join(pkgDir, 'etc', `${entry.report}.api.md`); + const committed = fs.existsSync(committedPath) ? fs.readFileSync(committedPath, 'utf8') : undefined; + + if (checkMode) { + if (committed === undefined) { + return 'missing'; + } + return produced === committed ? 'ok' : 'changed'; + } + if (produced !== committed) { + fs.writeFileSync(committedPath, produced); + return 'updated'; + } + return 'ok'; +} + +/* + * Report normalization + * -------------------- + * tsdown's d.mts rollups rename identifiers that collide when hoisted into the + * bundle's shared scope with `$N` suffixes (e.g. `Ajv$1`), and both whether a + * collision happens and which declaration keeps the bare name depend on how + * the bundle was chunked — which shifts whenever the module graph changes. + * Committing raw reports would therefore churn on surface-neutral changes. + * + * Blanket-stripping the suffixes is not sound either: distinct types sharing a + * source name would fold into one (masking reference-identity changes), and a + * text-global regex would also rewrite string-literal types that happen to + * contain `$`. + * + * So normalization works structurally on the report's declaration blocks: + * 1. Group declarations whose names differ only by a `$N` suffix. + * 2. Compute each group member's layout-independent identity: its blocks with + * every `$N` suffix erased, string-literal content untouched. + * 3. Members with identical identities are the same type duplicated across + * chunks: they all take the bare name and fold into one block. + * 4. Members with distinct identities are genuinely different types sharing a + * source name: they get deterministic, identity-ranked names (`Foo`, + * `Foo$2`, …) so they stay distinguishable regardless of chunk layout. + * 5. Renames apply in a single token pass that skips string and template + * literal content, blocks are deduped and re-sorted by name, and any + * rollup suffix that survives normalization fails the run loudly. + */ + +/** + * Apply `replace` to every match of `re` (global) in the code portions of + * `text`, leaving the content of '…'/"…" strings and `…` template literals + * untouched (template interpolations `${…}` are treated as code) and copying + * `// …` comment lines verbatim. + */ +function replaceOutsideStrings(text: string, re: RegExp, replace: (match: string) => string): string { + let out = ''; + let i = 0; + const n = text.length; + while (i < n) { + const ch = text[i]; + if (ch === "'" || ch === '"') { + let j = i + 1; + while (j < n && text[j] !== ch) { + j += text[j] === '\\' ? 2 : 1; + } + out += text.slice(i, Math.min(j + 1, n)); + i = j + 1; + } else if (ch === '`') { + out += '`'; + let j = i + 1; + while (j < n && text[j] !== '`') { + if (text[j] === '\\') { + out += text.slice(j, j + 2); + j += 2; + } else if (text[j] === '$' && text[j + 1] === '{') { + let depth = 1; + let k = j + 2; + while (k < n && depth > 0) { + if (text[k] === '{') depth += 1; + else if (text[k] === '}') depth -= 1; + k += 1; + } + out += '${' + replaceOutsideStrings(text.slice(j + 2, k - 1), re, replace) + '}'; + j = k; + } else { + out += text[j]; + j += 1; + } + } + if (j < n) { + out += '`'; + } + i = j + 1; + } else if (ch === '/' && text[i + 1] === '/') { + const lineEnd = text.indexOf('\n', i); + const end = lineEnd === -1 ? n : lineEnd; + out += text.slice(i, end); + i = end; + } else { + let j = i; + while (j < n && text[j] !== "'" && text[j] !== '"' && text[j] !== '`' && !(text[j] === '/' && text[j + 1] === '/')) { + j += 1; + } + out += text.slice(i, j).replace(re, replace); + i = j; + } + } + return out; +} + +/** Split fence content into blocks: blank lines at bracket depth zero are boundaries. */ +function splitBlocks(content: string): string[] { + const blocks: string[] = []; + let current: string[] = []; + let depth = 0; + for (const line of content.split('\n')) { + if (line.trim() === '' && depth === 0) { + if (current.length > 0) { + blocks.push(current.join('\n')); + current = []; + } + continue; + } + current.push(line); + depth = Math.max(0, depth + bracketDelta(line)); + } + if (current.length > 0) { + blocks.push(current.join('\n')); + } + return blocks; +} + +function bracketDelta(line: string): number { + let delta = 0; + let quote: string | null = null; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (quote !== null) { + if (ch === '\\') { + i += 1; + } else if (ch === quote) { + quote = null; + } + } else if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + } else if (ch === '/' && line[i + 1] === '/') { + break; + } else if (ch === '{' || ch === '(' || ch === '[') { + delta += 1; + } else if (ch === '}' || ch === ')' || ch === ']') { + delta -= 1; + } + } + return delta; +} + +const DECLARATION_RE = + /^(?:export\s+)?(?:declare\s+)?(?:abstract\s+)?(?:class|interface|enum|namespace|function|type|const|let|var)\s+([A-Za-z_$][\w$]*)/; + +/** The name a declaration block declares, or null for imports/footers/unrecognized blocks. */ +function declaredName(block: string): string | null { + for (const line of block.split('\n')) { + if (line.startsWith('//')) { + continue; + } + const match = line.match(DECLARATION_RE); + return match ? match[1] : null; + } + return null; +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const SUFFIX_TOKEN_RE = /\b[A-Za-z_][A-Za-z0-9_]*(?:\$\d+)+\b/g; + +/** Erase every rollup `$N` suffix outside string literals (identity computation only). */ +function eraseAllSuffixes(text: string): string { + return replaceOutsideStrings(text, SUFFIX_TOKEN_RE, token => token.replace(/(?:\$\d+)+$/, '')); +} + +/** + * Rename rollup-suffixed tokens that are local to one block — generic type + * parameters renamed because they collide with a hoisted top-level name (e.g. + * `StrictNullChecksWrapper` next to a top-level + * `Name` class). Such names are alpha-renamable within their block: each gets + * the bare base name unless that would capture an existing token in the block, + * in which case it gets the first free `base$K` by first-appearance order — + * deterministic regardless of which `$N` the bundler happened to pick. + */ +function renameLocalSuffixes(block: string, knownAliases: Set): string { + const order: string[] = []; + const found = new Set(); + replaceOutsideStrings(block, SUFFIX_TOKEN_RE, token => { + if (!knownAliases.has(token) && !found.has(token)) { + found.add(token); + order.push(token); + } + return token; + }); + if (order.length === 0) { + return block; + } + const placeholderOf = new Map(order.map((token, i) => [token, `\u0000${i}\u0000`])); + let text = replaceOutsideStrings(block, SUFFIX_TOKEN_RE, token => placeholderOf.get(token) ?? token); + const present = new Set(text.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []); + for (const raw of order) { + const base = raw.match(/^(.*[A-Za-z0-9_])(?:\$\d+)+$/)![1]; + let alias = base; + for (let k = 2; present.has(alias); k++) { + alias = `${base}$${k}`; + } + present.add(alias); + knownAliases.add(alias); + text = text.split(placeholderOf.get(raw)!).join(alias); + } + return text; +} + +function normalizeReport(text: string, label: string): string { + const lf = text.replace(/\r\n/g, '\n'); + const fenceOpen = lf.indexOf('```ts\n'); + const fenceClose = lf.lastIndexOf('\n```'); + if (fenceOpen === -1 || fenceClose === -1 || fenceClose <= fenceOpen) { + throw new Error(`${label}: unexpected report layout (no \`\`\`ts fence)`); + } + const prolog = lf.slice(0, fenceOpen + '```ts\n'.length); + const epilog = lf.slice(fenceClose); + const body = lf.slice(fenceOpen + '```ts\n'.length, fenceClose); + + // 1. Index declarations by name (a name can own several blocks: overloads). + const blocksByName = new Map(); + for (const block of splitBlocks(body)) { + const name = declaredName(block); + if (name !== null) { + const list = blocksByName.get(name) ?? []; + list.push(block); + blocksByName.set(name, list); + } + } + + // 2. Group `$N`-suffixed declarations with their base name and assign + // aliases by layout-independent identity. + const groups = new Map(); + for (const name of blocksByName.keys()) { + const base = name.match(/^(.*[A-Za-z0-9_])(?:\$\d+)+$/)?.[1]; + if (base !== undefined) { + const members = groups.get(base) ?? []; + members.push(name); + groups.set(base, members); + } + } + const rename = new Map(); + const assignedAliases = new Set(); + for (const [base, suffixed] of groups) { + const members = blocksByName.has(base) ? [base, ...suffixed] : suffixed; + const identities = new Map(members.map(raw => [raw, blocksByName.get(raw)!.map(eraseAllSuffixes).sort().join('\n\n')])); + // Exported declarations outrank internal ones so the public type keeps + // the bare name (`export class Ajv extends Ajv$2`, not the reverse). + const rankKey = (identity: string) => `${/^export\s/m.test(identity) ? 0 : 1}${identity}`; + const distinct = [...new Set(identities.values())].sort((a, b) => (rankKey(a) < rankKey(b) ? -1 : 1)); + for (const raw of members) { + const rank = distinct.indexOf(identities.get(raw)!); + const alias = rank === 0 ? base : `${base}$${rank + 1}`; + rename.set(raw, alias); + assignedAliases.add(alias); + } + } + + // 3. Apply all renames in one token pass (longest-first alternation, so a + // swap like Foo↔Foo$2 cannot cascade), skipping string literals. + let renamed = body; + if (rename.size > 0) { + const alternation = [...rename.keys()] + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join('|'); + const tokenRe = new RegExp(`\\b(?:${alternation})\\b`, 'g'); + renamed = replaceOutsideStrings(body, tokenRe, raw => rename.get(raw) ?? raw); + } + + // 4. Re-assemble: imports first (original order), declarations deduped and + // sorted by (name, body), footer comments last. + const imports: string[] = []; + const footers: string[] = []; + const decls: { name: string; text: string }[] = []; + const seen = new Set(); + for (const rawBlock of splitBlocks(renamed)) { + const block = renameLocalSuffixes(rawBlock, assignedAliases); + if (/^import[\s{]/.test(block)) { + if (!seen.has(block)) { + seen.add(block); + imports.push(block); + } + } else if (block.startsWith('// (No @packageDocumentation')) { + footers.push(block); + } else if (!seen.has(block)) { + // Duplicate blocks are the same type emitted into several chunks; + // after renaming they are byte-identical and fold into one. + seen.add(block); + decls.push({ name: declaredName(block) ?? '', text: block }); + } + } + decls.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.text < b.text ? -1 : a.text > b.text ? 1 : 0)); + + const normalizedBody = [...imports, ...decls.map(d => d.text), ...footers].join('\n\n'); + + // 5. Any surviving rollup suffix that is not one of our deterministic + // aliases means normalization missed a case — fail rather than commit + // a layout-dependent baseline. The scan covers the code body only: the + // markdown prolog/epilog contain the ```ts fence, whose backticks would + // desynchronize the scanner's template-literal tracking. + const leftovers = new Set(); + replaceOutsideStrings(normalizedBody, SUFFIX_TOKEN_RE, token => { + leftovers.add(token); + return token; + }); + for (const token of leftovers) { + if (!assignedAliases.has(token)) { + throw new Error(`${label}: leftover rollup suffix '${token}' — normalization missed a collision case`); + } + } + + return prolog + '\n' + normalizedBody + epilog; +} + +verifyManifestCoverage(); + +let failed = false; +let mismatches = 0; + +for (const pkg of PACKAGES) { + const expectedReports = new Set(pkg.entries.map(entry => `${entry.report}.api.md`)); + for (const entry of pkg.entries) { + const label = `${pkg.dir} → ${entry.report}.api.md`; + try { + const status = runEntry(pkg, entry); + if (status === 'changed' || status === 'missing') { + failed = true; + mismatches += 1; + console.error(`${status === 'missing' ? 'MISSING ' : 'CHANGED '} ${label}`); + } else { + console.log(`${status === 'updated' ? 'UPDATED ' : 'ok '} ${label}`); + } + } catch (error) { + failed = true; + console.error(`ERROR ${label}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Committed reports nothing produces anymore are stale baselines: fail the + // check on them, and clean them up on regeneration. + const etcDir = path.join(repoRoot, pkg.dir, 'etc'); + if (fs.existsSync(etcDir)) { + for (const file of fs.readdirSync(etcDir)) { + if (!file.endsWith('.api.md') || expectedReports.has(file)) { + continue; + } + if (checkMode) { + failed = true; + console.error(`ORPHAN ${pkg.dir}/etc/${file} — no PACKAGES entry produces it; remove it (pnpm api-report does)`); + } else { + fs.rmSync(path.join(etcDir, file)); + console.log(`REMOVED ${pkg.dir}/etc/${file} (orphan)`); + } + } + } + fs.rmSync(path.join(repoRoot, pkg.dir, '.api-extractor-tmp'), { recursive: true, force: true }); +} + +if (failed) { + if (mismatches > 0) { + console.error( + '\nThe built public type surface differs from the committed API report(s) above.\n' + + 'If the change is intentional: run `pnpm api-report`, review the report diff,\n' + + 'commit it together with your change (and a changeset if consumer-facing).\n' + + 'See the header of scripts/generate-api-reports.ts.' + ); + } + process.exit(1); +} From 9c5b56bdb66557c1e7098ae81754b760ec04749d Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:11:51 +0100 Subject: [PATCH 05/37] fix(codemod): flag removed task options instead of migrating them; drop stale task artifacts (#2290) --- .../codemod-flag-removed-task-options.md | 5 + .changeset/extract-task-manager.md | 10 -- .changeset/fix-task-session-isolation.md | 5 - .../v1-to-v2/transforms/mcpServerApi.ts | 127 +++--------------- .../v1-to-v2/transforms/mcpServerApi.test.ts | 59 ++++++++ ....test.ts => transportResumability.test.ts} | 0 6 files changed, 83 insertions(+), 123 deletions(-) create mode 100644 .changeset/codemod-flag-removed-task-options.md delete mode 100644 .changeset/extract-task-manager.md delete mode 100644 .changeset/fix-task-session-isolation.md rename test/integration/test/{taskResumability.test.ts => transportResumability.test.ts} (100%) diff --git a/.changeset/codemod-flag-removed-task-options.md b/.changeset/codemod-flag-removed-task-options.md new file mode 100644 index 0000000000..7eec3cf127 --- /dev/null +++ b/.changeset/codemod-flag-removed-task-options.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/codemod': patch +--- + +The v1→v2 codemod no longer rewrites `taskStore`/`taskMessageQueue` McpServer constructor options into `capabilities.tasks` — that target does not exist in v2 (the experimental tasks runtime was removed, SEP-2663). The codemod now leaves the code untouched and emits an action-required diagnostic telling migrators to remove the option, matching the removal guidance already given for `experimental/tasks` imports and the migration guide. diff --git a/.changeset/extract-task-manager.md b/.changeset/extract-task-manager.md deleted file mode 100644 index 6a72182837..0000000000 --- a/.changeset/extract-task-manager.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@modelcontextprotocol/core": minor -"@modelcontextprotocol/client": minor -"@modelcontextprotocol/server": minor ---- - -refactor: extract task orchestration from Protocol into TaskManager - -**Breaking changes:** -- `taskStore`, `taskMessageQueue`, `defaultTaskPollInterval`, and `maxTaskQueueSize` moved from `ProtocolOptions` to `capabilities.tasks` on `ClientOptions`/`ServerOptions` diff --git a/.changeset/fix-task-session-isolation.md b/.changeset/fix-task-session-isolation.md deleted file mode 100644 index 7220673374..0000000000 --- a/.changeset/fix-task-session-isolation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/core': patch ---- - -Fix InMemoryTaskStore to enforce session isolation. Previously, sessionId was accepted but ignored on all TaskStore methods, allowing any session to enumerate, read, and mutate tasks created by other sessions. The store now persists sessionId at creation time and enforces ownership on all reads and writes. diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts index 9efc2d5839..706c1e6e66 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -121,7 +121,7 @@ export const mcpServerApiTransform: Transform = { } } - changesCount += migrateConstructorTaskOptions(sourceFile, diagnostics); + flagRemovedTaskOptions(sourceFile, diagnostics); return { changesCount, diagnostics }; } @@ -414,11 +414,17 @@ function migrateResourceCall(call: CallExpression, _sourceFile: SourceFile): boo const TASK_OPTIONS = ['taskStore', 'taskMessageQueue'] as const; -function migrateConstructorTaskOptions(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { +/** + * Flag v1 task runtime options on the McpServer constructor as removed. + * + * The experimental tasks runtime was removed in v2 (SEP-2663) with no replacement, so + * these options cannot be migrated automatically. Emit an action-required diagnostic + * matching the importMap removal entry for `experimental/tasks`; the source is left + * untouched. + */ +function flagRemovedTaskOptions(sourceFile: SourceFile, diagnostics: Diagnostic[]): void { const localName = resolveLocalImportName(sourceFile, 'McpServer'); - if (!localName) return 0; - - let changes = 0; + if (!localName) return; for (const node of sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression)) { if (node.wasForgotten()) continue; @@ -431,110 +437,15 @@ function migrateConstructorTaskOptions(sourceFile: SourceFile, diagnostics: Diag const optionsArg = args[1]!; if (!Node.isObjectLiteralExpression(optionsArg)) continue; - // Check if any task options are present at the top level - const propsToMove: string[] = []; for (const propName of TASK_OPTIONS) { - if (optionsArg.getProperty(propName)) { - propsToMove.push(propName); - } - } - if (propsToMove.length === 0) continue; - - // Find the tasks object's position within the options text using AST, - // then do all mutations via a single text replacement to avoid node invalidation. - const capabilitiesProp = optionsArg.getProperty('capabilities'); - let tasksObjStart = -1; - let tasksObjEnd = -1; - const optionsStart = optionsArg.getStart(); - if (capabilitiesProp && Node.isPropertyAssignment(capabilitiesProp)) { - const capInit = capabilitiesProp.getInitializer(); - if (capInit && Node.isObjectLiteralExpression(capInit)) { - const tasksProp = capInit.getProperty('tasks'); - if (tasksProp && Node.isPropertyAssignment(tasksProp)) { - const tasksInit = tasksProp.getInitializer(); - if (tasksInit && Node.isObjectLiteralExpression(tasksInit)) { - tasksObjStart = tasksInit.getStart() - optionsStart; - tasksObjEnd = tasksInit.getEnd() - optionsStart; - } - } - } - } - - if (tasksObjStart === -1) { - for (const propName of propsToMove) { - diagnostics.push( - actionRequired( - sourceFile.getFilePath(), - node, - `Move '${propName}' from McpServer options into capabilities.tasks — v2 expects task runtime options inside the tasks capability.` - ) - ); - } - continue; - } - - // Single text replacement: remove top-level props and insert into tasks object. - // Use AST nodes (already located via getProperty) to get brace-balanced text and - // exact positions, avoiding regex truncation on values containing commas/braces. - // Collect all properties first, then process in reverse position order so each - // removal doesn't invalidate the positions of subsequent removals. - let optionsText = optionsArg.getText(); - const argStart = optionsArg.getStart(); - const propsWithPositions: { text: string; start: number; end: number }[] = []; - for (const propName of propsToMove) { - const prop = optionsArg.getProperty(propName); - if (!prop) continue; - propsWithPositions.push({ - text: prop.getText(), - start: prop.getStart() - argStart, - end: prop.getEnd() - argStart - }); + if (!optionsArg.getProperty(propName)) continue; + diagnostics.push( + actionRequired( + sourceFile.getFilePath(), + node, + `Remove '${propName}' from McpServer options — experimental tasks removed in v2 (SEP-2663 — tasks moved to the Extensions Track). No v2 equivalent.` + ) + ); } - const propTexts = propsWithPositions.map(p => p.text); - - // Remove in reverse position order so earlier positions remain valid - const sortedProps = propsWithPositions.toSorted((a, b) => b.start - a.start); - for (const { start, end } of sortedProps) { - let remStart = start; - let remEnd = end; - // Consume trailing comma and whitespace - const afterProp = optionsText.slice(remEnd); - const trailingMatch = afterProp.match(/^\s*,?\s*/); - if (trailingMatch) { - remEnd += trailingMatch[0].length; - } - // Consume leading whitespace/newline - const beforeProp = optionsText.slice(0, remStart); - const leadingMatch = beforeProp.match(/[\n\r]?\s*$/); - if (leadingMatch) { - remStart -= leadingMatch[0].length; - } - optionsText = optionsText.slice(0, remStart) + optionsText.slice(remEnd); - // Adjust tasks position if removal was before it - if (remStart < tasksObjStart) { - const shift = remEnd - remStart; - tasksObjStart -= shift; - tasksObjEnd -= shift; - } - } - - if (propTexts.length === 0) continue; - - // Insert into the tasks object (just before its closing brace) - const tasksText = optionsText.slice(tasksObjStart, tasksObjEnd); - const closingBrace = tasksText.lastIndexOf('}'); - const before = tasksText.slice(0, closingBrace).trimEnd(); - const sep = before.length > 1 ? ',\n' : '\n'; - const newTasksText = before + sep + propTexts.join(',\n') + '\n' + tasksText.slice(closingBrace); - optionsText = optionsText.slice(0, tasksObjStart) + newTasksText + optionsText.slice(tasksObjEnd); - - // Clean up double/trailing commas - optionsText = optionsText.replaceAll(/,(\s*,)/g, ','); - optionsText = optionsText.replaceAll(/,(\s*})/g, '$1'); - - optionsArg.replaceWithText(optionsText); - changes += propTexts.length; } - - return changes; } diff --git a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts index b18a1abb3f..461cfb5da0 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts @@ -307,4 +307,63 @@ describe('mcp-server-api transform', () => { expect(result).toContain('registerTool("ping", {}'); expect(result).not.toContain('z.object'); }); + + it('flags taskStore in McpServer options as removed without modifying code', () => { + const input = [ + `const server = new McpServer(`, + ` { name: 'test', version: '1.0' },`, + ` { taskStore: new InMemoryTaskStore() }`, + `);`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(sourceFile.getFullText()).toBe(MCP_IMPORT + input); + const taskDiags = result.diagnostics.filter(d => d.message.includes("'taskStore'")); + expect(taskDiags).toHaveLength(1); + expect(taskDiags[0]!.message).toContain('experimental tasks removed in v2 (SEP-2663'); + expect(taskDiags[0]!.message).toContain('No v2 equivalent'); + expect(taskDiags[0]!.insertComment).toBe(true); + }); + + it('flags each task option separately when both are present', () => { + const input = [ + `const server = new McpServer(`, + ` { name: 'test', version: '1.0' },`, + ` { taskStore: store, taskMessageQueue: queue }`, + `);`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(sourceFile.getFullText()).toBe(MCP_IMPORT + input); + expect(result.diagnostics.some(d => d.message.includes("'taskStore'"))).toBe(true); + expect(result.diagnostics.some(d => d.message.includes("'taskMessageQueue'"))).toBe(true); + }); + + it('does not move task options into capabilities.tasks even when present', () => { + const input = [ + `const server = new McpServer(`, + ` { name: 'test', version: '1.0' },`, + ` { taskStore: store, capabilities: { tasks: {} } }`, + `);`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(sourceFile.getFullText()).toBe(MCP_IMPORT + input); + expect(sourceFile.getFullText()).toContain('taskStore: store'); + expect(result.diagnostics.some(d => d.message.includes("'taskStore'"))).toBe(true); + }); + + it('emits no task diagnostics for McpServer options without task options', () => { + const input = [`const server = new McpServer({ name: 'test', version: '1.0' }, { instructions: 'hi' });`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(result.diagnostics).toHaveLength(0); + }); }); diff --git a/test/integration/test/taskResumability.test.ts b/test/integration/test/transportResumability.test.ts similarity index 100% rename from test/integration/test/taskResumability.test.ts rename to test/integration/test/transportResumability.test.ts From 571d973f8640d23d31df66d64df8a0e7b2ec7253 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:16:41 +0100 Subject: [PATCH 06/37] chore(core): repin 2026-07-28 spec reference types; freeze released-revision generation (#2291) --- .changeset/spec-types-2026-repin.md | 7 +++++ .github/workflows/update-spec-types.yml | 11 +++++++ packages/core/src/types/README.md | 21 ++++++++++++++ .../core/src/types/spec.types.2026-07-28.ts | 29 ++++++++++++++----- .../core/test/spec.types.2026-07-28.test.ts | 13 +++++++-- scripts/fetch-spec-types.ts | 21 ++++++++++++++ 6 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 .changeset/spec-types-2026-repin.md create mode 100644 packages/core/src/types/README.md diff --git a/.changeset/spec-types-2026-repin.md b/.changeset/spec-types-2026-repin.md new file mode 100644 index 0000000000..dbf757cd4e --- /dev/null +++ b/.changeset/spec-types-2026-repin.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Internal: regenerate the 2026-07-28 spec reference types from the latest draft schema (`DiscoverResult` now extends `CacheableResult`; `ElicitationCompleteNotificationParams` extracted as a named interface) and document the anchor lifecycle policy. Released-revision spec-type generation is now pinned to a fixed spec commit; draft anchors keep floating via the nightly refresh PRs. No public API or runtime behavior changes. diff --git a/.github/workflows/update-spec-types.yml b/.github/workflows/update-spec-types.yml index 482fb04213..41c1303b0a 100644 --- a/.github/workflows/update-spec-types.yml +++ b/.github/workflows/update-spec-types.yml @@ -1,3 +1,14 @@ +# Nightly refresh of the draft-tracking spec anchor (2026-07-28). +# +# Anchor lifecycle (see packages/core/src/types/README.md for the full policy): +# - Draft anchors float: this job regenerates the draft-tracking anchor from the +# latest upstream draft schema and, on drift, opens a refresh PR for review. +# It only ever proposes — it never merges. +# - Released anchors are frozen: generation for released revisions is pinned in +# scripts/fetch-spec-types.ts (RELEASED_REVISION_PINS) and is not refreshed by +# this job. Repinning a released revision — including the freeze of a newly +# published revision, when its schema moves out of schema/draft/ — must land +# in the same commit that retargets this workflow. name: Update Spec Types on: diff --git a/packages/core/src/types/README.md b/packages/core/src/types/README.md new file mode 100644 index 0000000000..23c1a1e7c6 --- /dev/null +++ b/packages/core/src/types/README.md @@ -0,0 +1,21 @@ +# Spec reference types ("anchors") + +The `spec.types..ts` files in this directory are vendored, verbatim copies of the MCP specification's normative `schema.ts`, one file per protocol revision. Each file is generated by `pnpm run fetch:spec-types [version] [sha]` (`scripts/fetch-spec-types.ts`): the +upstream schema is fetched at a specific spec commit, a provenance header recording that commit is prepended, and the result is formatted with the project's prettier config — no other transformation. + +They are reference-only test oracles: the comparison suites in `packages/core/test/spec.types..test.ts` check the SDK's own types against them. They are not exported from any barrel and must never be imported by runtime code. + +## Lifecycle policy + +1. **Released revisions are frozen.** Once a protocol revision is published under `schema//` in the spec repository, its anchor regenerates only from the pinned spec commit recorded in `RELEASED_REVISION_PINS` (`scripts/fetch-spec-types.ts`) — never from the latest + upstream commit. Moving that pin, including the freeze of a newly published revision (when its generation source switches from `schema/draft/` to `schema//`), must land in the same commit that retargets the nightly update workflow + (`.github/workflows/update-spec-types.yml`), so the anchor and the automation that maintains it can never disagree about the source of truth. + +2. **Draft anchors float only via reviewed refresh PRs.** The anchor for an unreleased revision tracks the spec repository's `schema/draft/schema.ts`. The nightly workflow regenerates it from the latest upstream commit and, when the result differs from what is checked in, opens + (or updates) a refresh PR. Manual refreshes follow the same path: regenerate, then propose the diff in a PR. + +3. **The bot proposes; it never auto-merges.** Automated refreshes always go through a pull request that a maintainer reviews and merges. No automation pushes anchor changes directly to `main` or merges its own PRs. A refresh PR that breaks the comparison suites is the desired + signal — it is fixed in that PR, not bypassed. + +4. **Generated twins update atomically with their anchor.** If artifacts derived from an anchor (for example vendored JSON schemas or generated validators) are ever checked into this repository, any refresh that changes the anchor must regenerate those artifacts in the same + commit. The anchor and its derived twins must never be out of sync at any commit on `main`. This clause becomes operative the day such generated artifacts are first vendored. diff --git a/packages/core/src/types/spec.types.2026-07-28.ts b/packages/core/src/types/spec.types.2026-07-28.ts index 7305df0462..1b222b9896 100644 --- a/packages/core/src/types/spec.types.2026-07-28.ts +++ b/packages/core/src/types/spec.types.2026-07-28.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: 9d700ed62dcf86cb77475c9b81930611a9182f46 + * Last updated from commit: 77cb26481e439d3437bc2bd6ccd19fcae86bb1ec * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: pnpm run fetch:spec-types 2026-07-28 @@ -569,7 +569,7 @@ export interface DiscoverRequest extends JSONRPCRequest { * * @category `server/discover` */ -export interface DiscoverResult extends Result { +export interface DiscoverResult extends CacheableResult { /** * MCP Protocol Versions this server supports. The client should choose a * version from this list for use in subsequent requests. @@ -674,6 +674,9 @@ export interface ClientCapabilities { * (e.g., "io.modelcontextprotocol/oauth-client-credentials"), and values are * per-extension settings objects. An empty object indicates support with no settings. * + * Keys MUST follow the {@link MetaObject | `_meta` key naming rules}, with a + * mandatory prefix. + * * @example Extensions — MCP Apps (UI) extension with MIME type support * {@includeCode ./examples/ClientCapabilities/extensions-ui-mime-types.json} */ @@ -768,6 +771,9 @@ export interface ServerCapabilities { * (e.g., "io.modelcontextprotocol/tasks"), and values are per-extension settings * objects. An empty object indicates support with no settings. * + * Keys MUST follow the {@link MetaObject | `_meta` key naming rules}, with a + * mandatory prefix. + * * @example Extensions — Tasks extension support * {@includeCode ./examples/ServerCapabilities/extensions-tasks.json} */ @@ -2963,6 +2969,18 @@ export interface ElicitResult { content?: { [key: string]: string | number | boolean | string[] }; } +/** + * Parameters for a {@link ElicitationCompleteNotification | notifications/elicitation/complete} notification. + * + * @category `notifications/elicitation/complete` + */ +export interface ElicitationCompleteNotificationParams extends NotificationParams { + /** + * The ID of the elicitation that completed. + */ + elicitationId: string; +} + /** * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. * @@ -2973,12 +2991,7 @@ export interface ElicitResult { */ export interface ElicitationCompleteNotification extends JSONRPCNotification { method: 'notifications/elicitation/complete'; - params: { - /** - * The ID of the elicitation that completed. - */ - elicitationId: string; - }; + params: ElicitationCompleteNotificationParams; } /* Client messages */ diff --git a/packages/core/test/spec.types.2026-07-28.test.ts b/packages/core/test/spec.types.2026-07-28.test.ts index 064221963a..74f6d77750 100644 --- a/packages/core/test/spec.types.2026-07-28.test.ts +++ b/packages/core/test/spec.types.2026-07-28.test.ts @@ -358,6 +358,13 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, + ElicitationCompleteNotificationParams: ( + sdk: SDKTypes.ElicitationCompleteNotificationParams, + spec: SpecTypes.ElicitationCompleteNotificationParams + ) => { + sdk = spec; + spec = sdk; + }, ElicitationCompleteNotification: ( sdk: WithJSONRPC, spec: SpecTypes.ElicitationCompleteNotification @@ -451,7 +458,9 @@ const MISSING_SDK_TYPES_2026_07_28 = [ // SEP-2575 sessionless discovery: the SDK ships the wire shapes // (DiscoverRequestSchema / DiscoverResultSchema), but the 2026-07-28 shapes embed the // required `_meta` envelope (request) and required `resultType` (result → MRTR PR), - // so they do not match yet; DiscoverResultResponse is a response wrapper (→ MRTR PR): + // and DiscoverResult now extends CacheableResult (required `ttlMs`/`cacheScope` + // → PR for SEP-2549), so they do not match yet; DiscoverResultResponse is a + // response wrapper (→ MRTR PR): 'DiscoverRequest', 'DiscoverResult', 'DiscoverResultResponse', @@ -518,7 +527,7 @@ describe('Spec Types (2026-07-28)', () => { expect(specTypes).toContain('DiscoverRequest'); expect(specTypes).toContain('InputRequiredResult'); expect(specTypes).toContain('SubscriptionsListenRequest'); - expect(specTypes).toHaveLength(150); + expect(specTypes).toHaveLength(151); }); it('should only allowlist types that exist in the 2026-07-28 schema', () => { diff --git a/scripts/fetch-spec-types.ts b/scripts/fetch-spec-types.ts index b0db8d486f..e1b1ee0eab 100644 --- a/scripts/fetch-spec-types.ts +++ b/scripts/fetch-spec-types.ts @@ -27,6 +27,23 @@ const UPSTREAM_SCHEMA_DIRS: Record = { '2026-07-28': 'draft' }; +/** + * Generation pin per released revision. Released revisions are frozen: without + * an explicit SHA argument, their types are regenerated from the pinned spec + * commit below — never from the latest upstream commit — so a released anchor + * can only change through a deliberate, reviewed repin. Moving a pin (or + * freezing a newly released revision) must land in the same commit that + * retargets `.github/workflows/update-spec-types.yml`. + * + * Draft-tracking revisions have no entry and float to the latest upstream + * commit via the nightly workflow's refresh PRs. + * + * See `packages/core/src/types/README.md` for the full lifecycle policy. + */ +const RELEASED_REVISION_PINS: Partial> = { + '2025-11-25': '0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65' +}; + interface GitHubCommit { sha: string; } @@ -59,10 +76,14 @@ async function fetchSpecTypes(version: SpecVersion, sha: string): Promise { + const pinnedSHA = RELEASED_REVISION_PINS[version]; let sha: string; if (providedSHA) { console.log(`[${version}] Using provided SHA: ${providedSHA}`); sha = providedSHA; + } else if (pinnedSHA) { + console.log(`[${version}] Using pinned SHA for released revision: ${pinnedSHA}`); + sha = pinnedSHA; } else { console.log(`[${version}] Fetching latest commit SHA...`); sha = await fetchLatestSHA(version); From fd25778deceea08ed8ec12827af716b002e86efe Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:27:02 +0100 Subject: [PATCH 07/37] =?UTF-8?q?test:=20wire-safety=20nets=20=E2=80=94=20?= =?UTF-8?q?cross-bundle=20error=20pin=20and=20spec=20example=20corpus=20(#?= =?UTF-8?q?2292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/spec-corpus-and-leak-net.md | 5 + .prettierignore | 4 + package.json | 1 + .../call-tool-with-progress-token.json | 12 + .../2025-11-25/CallToolRequest/call-tool.json | 9 + .../2025-11-25/CallToolResult/is-error.json | 9 + .../2025-11-25/CallToolResult/structured.json | 12 + .../2025-11-25/CallToolResult/text.json | 8 + .../CancelledNotification/cancelled.json | 7 + .../2025-11-25/CompleteRequest/complete.json | 13 + .../CompleteResult/complete-result.json | 7 + .../CreateMessageRequest/create-message.json | 25 ++ .../create-message-result.json | 9 + .../CreateTaskResult/create-task.json | 10 + .../2025-11-25/ElicitRequest/form.json | 16 + .../2025-11-25/ElicitResult/accept.json | 6 + .../2025-11-25/EmptyResult/empty.json | 1 + .../GetPromptRequest/get-prompt.json | 9 + .../GetPromptResult/get-prompt-result.json | 12 + .../2025-11-25/GetTaskRequest/get-task.json | 6 + .../InitializeRequest/initialize.json | 20 ++ .../InitializeResult/initialize-result.json | 22 ++ .../InitializedNotification/initialized.json | 3 + .../JSONRPCErrorResponse/error-envelope.json | 8 + .../JSONRPCRequest/request-envelope.json | 11 + .../result-envelope.json | 12 + .../list-prompts-result.json | 16 + .../list-templates-result.json | 11 + .../ListResourcesRequest/list-resources.json | 4 + .../list-resources-result.json | 11 + .../ListRootsRequest/list-roots.json | 3 + .../ListRootsResult/list-roots-result.json | 8 + .../ListToolsRequest/list-tools.json | 6 + .../ListToolsResult/list-tools-result.json | 19 ++ .../log-message.json | 11 + .../fixtures/2025-11-25/PingRequest/ping.json | 3 + .../ProgressNotification/progress.json | 9 + .../prompt-list-changed.json | 3 + .../ReadResourceRequest/read-resource.json | 6 + .../2025-11-25/ReadResourceResult/blob.json | 9 + .../2025-11-25/ReadResourceResult/text.json | 9 + .../resource-list-changed.json | 3 + .../resource-updated.json | 6 + .../roots-list-changed.json | 3 + .../2025-11-25/SetLevelRequest/set-level.json | 6 + .../SubscribeRequest/subscribe.json | 6 + .../task-augmented-call-params.json | 10 + .../TaskStatusNotification/task-status.json | 11 + .../tool-list-changed.json | 3 + .../UnsubscribeRequest/unsubscribe.json | 6 + .../AudioContent/audio-wav-content.json | 5 + .../image-file-contents.json | 5 + .../BooleanSchema/boolean-input-schema.json | 6 + .../CallToolRequest/call-tool-request.json | 19 ++ .../get-weather-tool-call-params.json | 14 + .../tool-call-params-with-progress-token.json | 15 + .../invalid-tool-input-error.json | 10 + .../result-with-array-structured-content.json | 13 + .../result-with-structured-content.json | 14 + .../result-with-unstructured-text.json | 10 + .../call-tool-result-response.json | 14 + .../user-requested-cancellation.json | 8 + .../user-requested-cancellation.json | 4 + ...elicitation-form-and-url-mode-support.json | 6 + .../elicitation-form-only-implicit.json | 3 + .../extensions-ui-mime-types.json | 7 + .../roots-minimum-baseline-support.json | 3 + ...-context-inclusion-support-deprecated.json | 5 + .../sampling-minimum-baseline-support.json | 3 + .../sampling-tool-use-support.json | 5 + .../CompleteRequest/completion-request.json | 23 ++ ...ompt-argument-completion-with-context.json | 23 ++ .../prompt-argument-completion.json | 18 + ...completion-values-with-more-available.json | 8 + .../single-completion-value.json | 8 + .../completion-result-response.json | 12 + .../sampling-request.json | 25 ++ .../basic-request.json | 22 ++ .../follow-up-with-tool-results.json | 67 ++++ .../request-with-tools.json | 31 ++ .../CreateMessageResult/final-response.json | 9 + .../CreateMessageResult/text-response.json | 9 + .../tool-use-response.json | 23 ++ .../server-discover-request.json | 15 + .../server-capabilities-discovery.json | 15 + .../discover-result-response.json | 18 + .../ElicitRequest/elicitation-request.json | 18 + .../elicit-multiple-fields.json | 24 ++ .../elicit-single-field.json | 13 + .../elicit-sensitive-data.json | 6 + .../accept-url-mode-no-content.json | 3 + .../ElicitResult/input-multiple-fields.json | 8 + .../ElicitResult/input-single-field.json | 6 + .../elicitation-complete.json | 7 + ...bedded-file-resource-with-annotations.json | 13 + .../GetPromptRequest/get-prompt-request.json | 19 ++ .../get-code-review-prompt.json | 14 + .../GetPromptResult/code-review-prompt.json | 13 + .../get-prompt-result-response.json | 17 + .../image-png-content-with-annotations.json | 9 + ...icitation-and-sampling-input-requests.json | 33 ++ ...tation-and-sampling-and-request-state.json | 36 ++ ...quired-result-with-request-state-only.json | 4 + ...citation-and-sampling-input-responses.json | 17 + .../InternalError/unexpected-error.json | 4 + .../InvalidParamsError/invalid-cursor.json | 4 + .../invalid-tool-arguments.json | 4 + .../InvalidParamsError/unknown-prompt.json | 4 + .../InvalidParamsError/unknown-tool.json | 4 + .../list-prompts-request.json | 15 + .../prompts-list-with-cursor-and-ttl.json | 27 ++ .../list-prompts-result-response.json | 31 ++ .../list-resource-templates-request.json | 15 + ...ce-templates-list-with-cursor-and-ttl.json | 22 ++ ...st-resource-templates-result-response.json | 25 ++ .../list-resources-request.json | 15 + .../resources-list-with-cursor-and-ttl.json | 22 ++ .../list-resources-result-response.json | 26 ++ .../ListRootsRequest/list-roots-request.json | 4 + .../multiple-root-directories.json | 12 + .../single-root-directory.json | 8 + .../ListToolsRequest/list-tools-request.json | 15 + .../tools-list-with-cursor-and-ttl.json | 30 ++ .../list-tools-result-response.json | 34 ++ .../log-database-connection-failed.json | 15 + .../log-database-connection-failed.json | 11 + .../prompts-not-supported.json | 7 + .../missing-elicitation-capability.json | 13 + .../with-hints-and-priorities.json | 9 + .../NumberSchema/number-input-schema.json | 8 + .../list-with-cursor.json | 11 + .../2026-07-28/ParseError/invalid-json.json | 4 + .../progress-message.json | 10 + .../progress-message.json | 6 + .../prompts-list-changed.json | 4 + .../read-resource-request.json | 16 + .../file-resource-contents.json | 12 + ...ead-resource-result-response-with-ttl.json | 16 + .../read-resource-result-response.json | 14 + .../file-resource-with-annotations.json | 11 + .../ResourceLink/file-resource-link.json | 7 + .../resources-list-changed.json | 4 + .../file-resource-updated-notification.json | 7 + .../file-resource-updated.json | 3 + .../2026-07-28/Root/project-directory.json | 4 + .../multiple-content-blocks.json | 15 + .../SamplingMessage/single-content-block.json | 7 + .../completions-minimum-baseline-support.json | 3 + .../ServerCapabilities/extensions-tasks.json | 5 + .../logging-minimum-baseline-support.json | 3 + .../prompts-list-changed-notifications.json | 5 + .../prompts-minimum-baseline-support.json | 3 + .../resources-all-notifications.json | 6 + ...urces-list-changed-notifications-only.json | 5 + .../resources-minimum-baseline-support.json | 3 + ...n-to-individual-resource-updates-only.json | 5 + .../tools-list-changed-notifications.json | 5 + .../tools-minimum-baseline-support.json | 3 + .../StringSchema/email-input-schema.json | 9 + .../listen-acknowledged.json | 13 + .../listen-for-list-changes.json | 19 ++ .../2026-07-28/TextContent/text-content.json | 4 + .../text-file-contents.json | 5 + .../titled-color-multi-select-schema.json | 15 + .../titled-color-select-schema.json | 11 + .../Tool/tool-with-array-output-schema.json | 30 ++ .../tool-with-composition-input-schema.json | 28 ++ .../with-default-2020-12-input-schema.json | 12 + .../with-explicit-draft-07-input-schema.json | 13 + .../2026-07-28/Tool/with-no-parameters.json | 8 + ...-output-schema-for-structured-content.json | 33 ++ .../tools-list-changed.json | 4 + .../get-weather-tool-result.json | 10 + .../ToolUseContent/get-weather-tool-use.json | 8 + .../unsupported-version.json | 12 + .../color-multi-select-schema.json | 12 + .../color-select-schema.json | 7 + .../corpus/fixtures/2026-07-28/manifest.json | 312 ++++++++++++++++++ .../fixtures/rejection/batch-array-body.json | 11 + .../rejection/error-response-unknown-id.json | 12 + .../rejection/invalid-spec-params.json | 13 + .../notification-invalid-spec-params.json | 11 + .../notification-unknown-method.json | 8 + .../fixtures/rejection/null-request-id.json | 9 + .../request-extra-top-level-key.json | 11 + .../rejection/result-not-an-object.json | 9 + .../rejection/result-response-unknown-id.json | 9 + .../rejection/unknown-request-method.json | 11 + .../rejection/unregistered-spec-method.json | 13 + .../fixtures/rejection/valid-tools-call.json | 15 + .../rejection/wrong-jsonrpc-version.json | 9 + packages/core/test/corpus/specCorpus.test.ts | 188 +++++++++++ .../test/corpus/specCorpusDispatch.test.ts | 121 +++++++ .../types/crossBundleErrorRecognition.test.ts | 131 ++++++++ scripts/fetch-spec-examples.ts | 141 ++++++++ 195 files changed, 3062 insertions(+) create mode 100644 .changeset/spec-corpus-and-leak-net.md create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool-with-progress-token.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/is-error.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/structured.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/text.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CancelledNotification/cancelled.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CompleteRequest/complete.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CompleteResult/complete-result.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CreateMessageRequest/create-message.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CreateMessageResult/create-message-result.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/CreateTaskResult/create-task.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ElicitRequest/form.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ElicitResult/accept.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/EmptyResult/empty.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/GetPromptRequest/get-prompt.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/GetPromptResult/get-prompt-result.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/GetTaskRequest/get-task.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/InitializeRequest/initialize.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/InitializeResult/initialize-result.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/InitializedNotification/initialized.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/JSONRPCErrorResponse/error-envelope.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/JSONRPCRequest/request-envelope.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/JSONRPCResultResponse/result-envelope.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ListPromptsResult/list-prompts-result.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ListResourceTemplatesResult/list-templates-result.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ListResourcesRequest/list-resources.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ListResourcesResult/list-resources-result.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ListRootsRequest/list-roots.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ListRootsResult/list-roots-result.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ListToolsRequest/list-tools.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ListToolsResult/list-tools-result.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/LoggingMessageNotification/log-message.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/PingRequest/ping.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ProgressNotification/progress.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/PromptListChangedNotification/prompt-list-changed.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ReadResourceRequest/read-resource.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/blob.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/text.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ResourceListChangedNotification/resource-list-changed.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ResourceUpdatedNotification/resource-updated.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/RootsListChangedNotification/roots-list-changed.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/SetLevelRequest/set-level.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/SubscribeRequest/subscribe.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/TaskAugmentedRequestParams/task-augmented-call-params.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/TaskStatusNotification/task-status.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/ToolListChangedNotification/tool-list-changed.json create mode 100644 packages/core/test/corpus/fixtures/2025-11-25/UnsubscribeRequest/unsubscribe.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/AudioContent/audio-wav-content.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/BlobResourceContents/image-file-contents.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/BooleanSchema/boolean-input-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CallToolRequest/call-tool-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/get-weather-tool-call-params.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/tool-call-params-with-progress-token.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/invalid-tool-input-error.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-array-structured-content.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-structured-content.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-unstructured-text.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CallToolResultResponse/call-tool-result-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CancelledNotification/user-requested-cancellation.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CancelledNotificationParams/user-requested-cancellation.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-and-url-mode-support.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-only-implicit.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/extensions-ui-mime-types.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/roots-minimum-baseline-support.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-context-inclusion-support-deprecated.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-minimum-baseline-support.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-tool-use-support.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CompleteRequest/completion-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion-with-context.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/multiple-completion-values-with-more-available.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/single-completion-value.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CompleteResultResponse/completion-result-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequest/sampling-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/basic-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/follow-up-with-tool-results.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/request-with-tools.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/final-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/text-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/tool-use-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/DiscoverRequest/server-discover-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ElicitRequest/elicitation-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-multiple-fields.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-single-field.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/accept-url-mode-no-content.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-multiple-fields.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-single-field.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/EmbeddedResource/embedded-file-resource-with-annotations.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequest/get-prompt-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequestParams/get-code-review-prompt.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/GetPromptResult/code-review-prompt.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/GetPromptResultResponse/get-prompt-result-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ImageContent/image-png-content-with-annotations.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/InputRequests/elicitation-and-sampling-input-requests.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-elicitation-and-sampling-and-request-state.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-request-state-only.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/InputResponses/elicitation-and-sampling-input-responses.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/InternalError/unexpected-error.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-cursor.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-tool-arguments.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-prompt.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-tool.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListPromptsRequest/list-prompts-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResult/prompts-list-with-cursor-and-ttl.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResultResponse/list-prompts-result-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesRequest/list-resource-templates-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResult/resource-templates-list-with-cursor-and-ttl.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResultResponse/list-resource-templates-result-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListResourcesRequest/list-resources-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResult/resources-list-with-cursor-and-ttl.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResultResponse/list-resources-result-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListRootsRequest/list-roots-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/multiple-root-directories.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/single-root-directory.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListToolsRequest/list-tools-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListToolsResult/tools-list-with-cursor-and-ttl.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ListToolsResultResponse/list-tools-result-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotification/log-database-connection-failed.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotificationParams/log-database-connection-failed.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/MethodNotFoundError/prompts-not-supported.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/MissingRequiredClientCapabilityError/missing-elicitation-capability.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ModelPreferences/with-hints-and-priorities.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/NumberSchema/number-input-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/PaginatedRequestParams/list-with-cursor.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ParseError/invalid-json.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ProgressNotification/progress-message.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ProgressNotificationParams/progress-message.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/PromptListChangedNotification/prompts-list-changed.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ReadResourceRequest/read-resource-request.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResult/file-resource-contents.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response-with-ttl.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/Resource/file-resource-with-annotations.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ResourceLink/file-resource-link.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ResourceListChangedNotification/resources-list-changed.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotification/file-resource-updated-notification.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotificationParams/file-resource-updated.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/Root/project-directory.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/multiple-content-blocks.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/single-content-block.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/completions-minimum-baseline-support.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/extensions-tasks.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/logging-minimum-baseline-support.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-list-changed-notifications.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-minimum-baseline-support.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-all-notifications.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-list-changed-notifications-only.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-minimum-baseline-support.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-subscription-to-individual-resource-updates-only.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-list-changed-notifications.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-minimum-baseline-support.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/StringSchema/email-input-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsAcknowledgedNotification/listen-acknowledged.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsListenRequest/listen-for-list-changes.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/TextContent/text-content.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/TextResourceContents/text-file-contents.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/TitledMultiSelectEnumSchema/titled-color-multi-select-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/TitledSingleSelectEnumSchema/titled-color-select-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-array-output-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-composition-input-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/Tool/with-default-2020-12-input-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/Tool/with-explicit-draft-07-input-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/Tool/with-no-parameters.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/Tool/with-output-schema-for-structured-content.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ToolListChangedNotification/tools-list-changed.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ToolResultContent/get-weather-tool-result.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ToolUseContent/get-weather-tool-use.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/UnsupportedProtocolVersionError/unsupported-version.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/UntitledMultiSelectEnumSchema/color-multi-select-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/UntitledSingleSelectEnumSchema/color-select-schema.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/manifest.json create mode 100644 packages/core/test/corpus/fixtures/rejection/batch-array-body.json create mode 100644 packages/core/test/corpus/fixtures/rejection/error-response-unknown-id.json create mode 100644 packages/core/test/corpus/fixtures/rejection/invalid-spec-params.json create mode 100644 packages/core/test/corpus/fixtures/rejection/notification-invalid-spec-params.json create mode 100644 packages/core/test/corpus/fixtures/rejection/notification-unknown-method.json create mode 100644 packages/core/test/corpus/fixtures/rejection/null-request-id.json create mode 100644 packages/core/test/corpus/fixtures/rejection/request-extra-top-level-key.json create mode 100644 packages/core/test/corpus/fixtures/rejection/result-not-an-object.json create mode 100644 packages/core/test/corpus/fixtures/rejection/result-response-unknown-id.json create mode 100644 packages/core/test/corpus/fixtures/rejection/unknown-request-method.json create mode 100644 packages/core/test/corpus/fixtures/rejection/unregistered-spec-method.json create mode 100644 packages/core/test/corpus/fixtures/rejection/valid-tools-call.json create mode 100644 packages/core/test/corpus/fixtures/rejection/wrong-jsonrpc-version.json create mode 100644 packages/core/test/corpus/specCorpus.test.ts create mode 100644 packages/core/test/corpus/specCorpusDispatch.test.ts create mode 100644 packages/core/test/types/crossBundleErrorRecognition.test.ts create mode 100644 scripts/fetch-spec-examples.ts diff --git a/.changeset/spec-corpus-and-leak-net.md b/.changeset/spec-corpus-and-leak-net.md new file mode 100644 index 0000000000..017ecd1501 --- /dev/null +++ b/.changeset/spec-corpus-and-leak-net.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Test-only hardening, no runtime changes: a spec example corpus harness (the draft revision's 86 example directories vendored from the specification repository plus a frozen hand-built 2025-11-25 corpus, with rejection-side fixtures routed through real dispatch), a cross-bundle typed-error recognition guard, and extended end-to-end draft-vocabulary leak coverage for hosted transports, SSE streams, and compatibility fallback paths. diff --git a/.prettierignore b/.prettierignore index d161704250..d3011b6d9a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,6 +13,10 @@ pnpm-lock.yaml **/src/types/spec.types.2025-11-25.ts **/src/types/spec.types.2026-07-28.ts +# Spec example corpora: vendored verbatim from the spec repository +# (fetch:spec-examples) or hand-built and frozen - byte-faithful artifacts. +packages/core/test/corpus/fixtures/ + # Batch test cloned repos and results packages/codemod/batch-test/repos packages/codemod/batch-test/results diff --git a/package.json b/package.json index 6517c23c24..0aa41cd91f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "mcp" ], "scripts": { + "fetch:spec-examples": "tsx scripts/fetch-spec-examples.ts", "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", "sync:snippets": "tsx scripts/sync-snippets.ts", "examples:simple-server:w": "pnpm --filter @modelcontextprotocol/examples-server exec tsx --watch src/simpleStreamableHttp.ts --oauth", diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool-with-progress-token.json b/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool-with-progress-token.json new file mode 100644 index 0000000000..a19422351c --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool-with-progress-token.json @@ -0,0 +1,12 @@ +{ + "method": "tools/call", + "params": { + "name": "get_weather", + "arguments": { + "location": "Berlin" + }, + "_meta": { + "progressToken": 7 + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool.json b/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool.json new file mode 100644 index 0000000000..a4a986baae --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool.json @@ -0,0 +1,9 @@ +{ + "method": "tools/call", + "params": { + "name": "get_weather", + "arguments": { + "location": "New York" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/is-error.json b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/is-error.json new file mode 100644 index 0000000000..6d1b416593 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/is-error.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Failed to fetch weather data: API rate limit exceeded" + } + ], + "isError": true +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/structured.json b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/structured.json new file mode 100644 index 0000000000..6c88b928ab --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/structured.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "{\"temperature\": 22.5}" + } + ], + "structuredContent": { + "temperature": 22.5, + "conditions": "Partly cloudy" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/text.json b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/text.json new file mode 100644 index 0000000000..1675638535 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/text.json @@ -0,0 +1,8 @@ +{ + "content": [ + { + "type": "text", + "text": "Current weather in New York: 72F, partly cloudy" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CancelledNotification/cancelled.json b/packages/core/test/corpus/fixtures/2025-11-25/CancelledNotification/cancelled.json new file mode 100644 index 0000000000..ec61e4267b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CancelledNotification/cancelled.json @@ -0,0 +1,7 @@ +{ + "method": "notifications/cancelled", + "params": { + "requestId": 12, + "reason": "User requested cancellation" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CompleteRequest/complete.json b/packages/core/test/corpus/fixtures/2025-11-25/CompleteRequest/complete.json new file mode 100644 index 0000000000..161168c398 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CompleteRequest/complete.json @@ -0,0 +1,13 @@ +{ + "method": "completion/complete", + "params": { + "ref": { + "type": "ref/prompt", + "name": "code_review" + }, + "argument": { + "name": "language", + "value": "py" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CompleteResult/complete-result.json b/packages/core/test/corpus/fixtures/2025-11-25/CompleteResult/complete-result.json new file mode 100644 index 0000000000..99b8c5a8d7 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CompleteResult/complete-result.json @@ -0,0 +1,7 @@ +{ + "completion": { + "values": ["python", "pytorch", "pyside"], + "total": 10, + "hasMore": true + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageRequest/create-message.json b/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageRequest/create-message.json new file mode 100644 index 0000000000..2376b12004 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageRequest/create-message.json @@ -0,0 +1,25 @@ +{ + "method": "sampling/createMessage", + "params": { + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } + } + ], + "modelPreferences": { + "hints": [ + { + "name": "claude-3-sonnet" + } + ], + "intelligencePriority": 0.8, + "speedPriority": 0.5 + }, + "systemPrompt": "You are a helpful assistant.", + "maxTokens": 100 + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageResult/create-message-result.json b/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageResult/create-message-result.json new file mode 100644 index 0000000000..74d3e63b6a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageResult/create-message-result.json @@ -0,0 +1,9 @@ +{ + "role": "assistant", + "content": { + "type": "text", + "text": "The capital of France is Paris." + }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CreateTaskResult/create-task.json b/packages/core/test/corpus/fixtures/2025-11-25/CreateTaskResult/create-task.json new file mode 100644 index 0000000000..1cbdac652e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CreateTaskResult/create-task.json @@ -0,0 +1,10 @@ +{ + "task": { + "taskId": "786af6b0-2779-48ed-9cc1-b8a8a25b8a86", + "status": "working", + "createdAt": "2025-11-25T10:30:00Z", + "ttl": 60000, + "pollInterval": 5000, + "lastUpdatedAt": "2025-11-25T10:30:05Z" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ElicitRequest/form.json b/packages/core/test/corpus/fixtures/2025-11-25/ElicitRequest/form.json new file mode 100644 index 0000000000..b7c223f106 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ElicitRequest/form.json @@ -0,0 +1,16 @@ +{ + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ElicitResult/accept.json b/packages/core/test/corpus/fixtures/2025-11-25/ElicitResult/accept.json new file mode 100644 index 0000000000..9b9b00f3a4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ElicitResult/accept.json @@ -0,0 +1,6 @@ +{ + "action": "accept", + "content": { + "name": "octocat" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/EmptyResult/empty.json b/packages/core/test/corpus/fixtures/2025-11-25/EmptyResult/empty.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/EmptyResult/empty.json @@ -0,0 +1 @@ +{} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/GetPromptRequest/get-prompt.json b/packages/core/test/corpus/fixtures/2025-11-25/GetPromptRequest/get-prompt.json new file mode 100644 index 0000000000..10aef03748 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/GetPromptRequest/get-prompt.json @@ -0,0 +1,9 @@ +{ + "method": "prompts/get", + "params": { + "name": "code_review", + "arguments": { + "code": "def hello():\n print('world')" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/GetPromptResult/get-prompt-result.json b/packages/core/test/corpus/fixtures/2025-11-25/GetPromptResult/get-prompt-result.json new file mode 100644 index 0000000000..fcff6dfbcc --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/GetPromptResult/get-prompt-result.json @@ -0,0 +1,12 @@ +{ + "description": "Code review prompt", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\ndef hello():\n print('world')" + } + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/GetTaskRequest/get-task.json b/packages/core/test/corpus/fixtures/2025-11-25/GetTaskRequest/get-task.json new file mode 100644 index 0000000000..b4bad8297a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/GetTaskRequest/get-task.json @@ -0,0 +1,6 @@ +{ + "method": "tasks/get", + "params": { + "taskId": "786af6b0-2779-48ed-9cc1-b8a8a25b8a86" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/InitializeRequest/initialize.json b/packages/core/test/corpus/fixtures/2025-11-25/InitializeRequest/initialize.json new file mode 100644 index 0000000000..e4a4ce60e1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/InitializeRequest/initialize.json @@ -0,0 +1,20 @@ +{ + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": { + "roots": { + "listChanged": true + }, + "sampling": {}, + "elicitation": { + "form": {} + } + }, + "clientInfo": { + "name": "example-client", + "title": "Example Client", + "version": "1.0.0" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/InitializeResult/initialize-result.json b/packages/core/test/corpus/fixtures/2025-11-25/InitializeResult/initialize-result.json new file mode 100644 index 0000000000..61db694725 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/InitializeResult/initialize-result.json @@ -0,0 +1,22 @@ +{ + "protocolVersion": "2025-11-25", + "capabilities": { + "logging": {}, + "prompts": { + "listChanged": true + }, + "resources": { + "subscribe": true, + "listChanged": true + }, + "tools": { + "listChanged": true + } + }, + "serverInfo": { + "name": "example-server", + "title": "Example Server", + "version": "1.0.0" + }, + "instructions": "Optional instructions for the client." +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/InitializedNotification/initialized.json b/packages/core/test/corpus/fixtures/2025-11-25/InitializedNotification/initialized.json new file mode 100644 index 0000000000..de0aae9156 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/InitializedNotification/initialized.json @@ -0,0 +1,3 @@ +{ + "method": "notifications/initialized" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCErrorResponse/error-envelope.json b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCErrorResponse/error-envelope.json new file mode 100644 index 0000000000..75b928f98b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCErrorResponse/error-envelope.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32602, + "message": "Unknown tool: invalid_tool_name" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCRequest/request-envelope.json b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCRequest/request-envelope.json new file mode 100644 index 0000000000..3c9b8d5943 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCRequest/request-envelope.json @@ -0,0 +1,11 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_weather", + "arguments": { + "location": "New York" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCResultResponse/result-envelope.json b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCResultResponse/result-envelope.json new file mode 100644 index 0000000000..09c1f92fee --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCResultResponse/result-envelope.json @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + { + "type": "text", + "text": "72F, partly cloudy" + } + ] + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListPromptsResult/list-prompts-result.json b/packages/core/test/corpus/fixtures/2025-11-25/ListPromptsResult/list-prompts-result.json new file mode 100644 index 0000000000..478b405ada --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListPromptsResult/list-prompts-result.json @@ -0,0 +1,16 @@ +{ + "prompts": [ + { + "name": "code_review", + "title": "Request Code Review", + "description": "Asks the LLM to analyze code quality", + "arguments": [ + { + "name": "code", + "description": "The code to review", + "required": true + } + ] + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListResourceTemplatesResult/list-templates-result.json b/packages/core/test/corpus/fixtures/2025-11-25/ListResourceTemplatesResult/list-templates-result.json new file mode 100644 index 0000000000..6798afa00e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListResourceTemplatesResult/list-templates-result.json @@ -0,0 +1,11 @@ +{ + "resourceTemplates": [ + { + "uriTemplate": "file:///{path}", + "name": "Project Files", + "title": "Project Files", + "description": "Access files in the project directory", + "mimeType": "application/octet-stream" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesRequest/list-resources.json b/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesRequest/list-resources.json new file mode 100644 index 0000000000..1114099b54 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesRequest/list-resources.json @@ -0,0 +1,4 @@ +{ + "method": "resources/list", + "params": {} +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesResult/list-resources-result.json b/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesResult/list-resources-result.json new file mode 100644 index 0000000000..96f8354bf5 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesResult/list-resources-result.json @@ -0,0 +1,11 @@ +{ + "resources": [ + { + "uri": "file:///project/src/main.rs", + "name": "main.rs", + "title": "Rust Software Application Main File", + "description": "Primary application entry point", + "mimeType": "text/x-rust" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListRootsRequest/list-roots.json b/packages/core/test/corpus/fixtures/2025-11-25/ListRootsRequest/list-roots.json new file mode 100644 index 0000000000..5237f0ba98 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListRootsRequest/list-roots.json @@ -0,0 +1,3 @@ +{ + "method": "roots/list" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListRootsResult/list-roots-result.json b/packages/core/test/corpus/fixtures/2025-11-25/ListRootsResult/list-roots-result.json new file mode 100644 index 0000000000..1fdaed5db4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListRootsResult/list-roots-result.json @@ -0,0 +1,8 @@ +{ + "roots": [ + { + "uri": "file:///home/user/projects/myproject", + "name": "My Project" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListToolsRequest/list-tools.json b/packages/core/test/corpus/fixtures/2025-11-25/ListToolsRequest/list-tools.json new file mode 100644 index 0000000000..2c264f8727 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListToolsRequest/list-tools.json @@ -0,0 +1,6 @@ +{ + "method": "tools/list", + "params": { + "cursor": "eyJwYWdlIjogM30=" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListToolsResult/list-tools-result.json b/packages/core/test/corpus/fixtures/2025-11-25/ListToolsResult/list-tools-result.json new file mode 100644 index 0000000000..cc0eca1eff --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListToolsResult/list-tools-result.json @@ -0,0 +1,19 @@ +{ + "tools": [ + { + "name": "get_weather", + "title": "Weather Provider", + "description": "Get current weather for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": ["location"] + } + } + ], + "nextCursor": "eyJwYWdlIjogNH0=" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/LoggingMessageNotification/log-message.json b/packages/core/test/corpus/fixtures/2025-11-25/LoggingMessageNotification/log-message.json new file mode 100644 index 0000000000..258aa12575 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/LoggingMessageNotification/log-message.json @@ -0,0 +1,11 @@ +{ + "method": "notifications/message", + "params": { + "level": "error", + "logger": "database", + "data": { + "error": "Connection failed", + "host": "localhost" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/PingRequest/ping.json b/packages/core/test/corpus/fixtures/2025-11-25/PingRequest/ping.json new file mode 100644 index 0000000000..9484af42e3 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/PingRequest/ping.json @@ -0,0 +1,3 @@ +{ + "method": "ping" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ProgressNotification/progress.json b/packages/core/test/corpus/fixtures/2025-11-25/ProgressNotification/progress.json new file mode 100644 index 0000000000..5c78f7c64f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ProgressNotification/progress.json @@ -0,0 +1,9 @@ +{ + "method": "notifications/progress", + "params": { + "progressToken": 12, + "progress": 50, + "total": 100, + "message": "Halfway there" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/PromptListChangedNotification/prompt-list-changed.json b/packages/core/test/corpus/fixtures/2025-11-25/PromptListChangedNotification/prompt-list-changed.json new file mode 100644 index 0000000000..ba487a2d5a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/PromptListChangedNotification/prompt-list-changed.json @@ -0,0 +1,3 @@ +{ + "method": "notifications/prompts/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceRequest/read-resource.json b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceRequest/read-resource.json new file mode 100644 index 0000000000..fcebffa3d1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceRequest/read-resource.json @@ -0,0 +1,6 @@ +{ + "method": "resources/read", + "params": { + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/blob.json b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/blob.json new file mode 100644 index 0000000000..527388bde2 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/blob.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "file:///project/assets/logo.png", + "mimeType": "image/png", + "blob": "iVBORw0KGgoAAAANSUhEUg==" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/text.json b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/text.json new file mode 100644 index 0000000000..1396a6bc0a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/text.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "file:///project/src/main.rs", + "mimeType": "text/x-rust", + "text": "fn main() {\n println(\"Hello world\");\n}" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ResourceListChangedNotification/resource-list-changed.json b/packages/core/test/corpus/fixtures/2025-11-25/ResourceListChangedNotification/resource-list-changed.json new file mode 100644 index 0000000000..5bec1a4c79 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ResourceListChangedNotification/resource-list-changed.json @@ -0,0 +1,3 @@ +{ + "method": "notifications/resources/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ResourceUpdatedNotification/resource-updated.json b/packages/core/test/corpus/fixtures/2025-11-25/ResourceUpdatedNotification/resource-updated.json new file mode 100644 index 0000000000..9f942d4314 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ResourceUpdatedNotification/resource-updated.json @@ -0,0 +1,6 @@ +{ + "method": "notifications/resources/updated", + "params": { + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/RootsListChangedNotification/roots-list-changed.json b/packages/core/test/corpus/fixtures/2025-11-25/RootsListChangedNotification/roots-list-changed.json new file mode 100644 index 0000000000..dd94884afb --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/RootsListChangedNotification/roots-list-changed.json @@ -0,0 +1,3 @@ +{ + "method": "notifications/roots/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/SetLevelRequest/set-level.json b/packages/core/test/corpus/fixtures/2025-11-25/SetLevelRequest/set-level.json new file mode 100644 index 0000000000..849853b545 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/SetLevelRequest/set-level.json @@ -0,0 +1,6 @@ +{ + "method": "logging/setLevel", + "params": { + "level": "info" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/SubscribeRequest/subscribe.json b/packages/core/test/corpus/fixtures/2025-11-25/SubscribeRequest/subscribe.json new file mode 100644 index 0000000000..b478078154 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/SubscribeRequest/subscribe.json @@ -0,0 +1,6 @@ +{ + "method": "resources/subscribe", + "params": { + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/TaskAugmentedRequestParams/task-augmented-call-params.json b/packages/core/test/corpus/fixtures/2025-11-25/TaskAugmentedRequestParams/task-augmented-call-params.json new file mode 100644 index 0000000000..881f113ff9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/TaskAugmentedRequestParams/task-augmented-call-params.json @@ -0,0 +1,10 @@ +{ + "task": { + "ttl": 60000 + }, + "_meta": { + "io.modelcontextprotocol/related-task": { + "taskId": "786af6b0-2779-48ed-9cc1-b8a8a25b8a86" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/TaskStatusNotification/task-status.json b/packages/core/test/corpus/fixtures/2025-11-25/TaskStatusNotification/task-status.json new file mode 100644 index 0000000000..170b49bebe --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/TaskStatusNotification/task-status.json @@ -0,0 +1,11 @@ +{ + "method": "notifications/tasks/status", + "params": { + "taskId": "786af6b0-2779-48ed-9cc1-b8a8a25b8a86", + "status": "working", + "statusMessage": "Processing input", + "createdAt": "2025-11-25T10:30:00Z", + "ttl": 60000, + "lastUpdatedAt": "2025-11-25T10:30:05Z" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ToolListChangedNotification/tool-list-changed.json b/packages/core/test/corpus/fixtures/2025-11-25/ToolListChangedNotification/tool-list-changed.json new file mode 100644 index 0000000000..c9c29c4e10 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ToolListChangedNotification/tool-list-changed.json @@ -0,0 +1,3 @@ +{ + "method": "notifications/tools/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/UnsubscribeRequest/unsubscribe.json b/packages/core/test/corpus/fixtures/2025-11-25/UnsubscribeRequest/unsubscribe.json new file mode 100644 index 0000000000..ce9b642f8e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/UnsubscribeRequest/unsubscribe.json @@ -0,0 +1,6 @@ +{ + "method": "resources/unsubscribe", + "params": { + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/AudioContent/audio-wav-content.json b/packages/core/test/corpus/fixtures/2026-07-28/AudioContent/audio-wav-content.json new file mode 100644 index 0000000000..1816ec4416 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/AudioContent/audio-wav-content.json @@ -0,0 +1,5 @@ +{ + "type": "audio", + "data": "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=", + "mimeType": "audio/wav" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/BlobResourceContents/image-file-contents.json b/packages/core/test/corpus/fixtures/2026-07-28/BlobResourceContents/image-file-contents.json new file mode 100644 index 0000000000..5b9ef07c9c --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/BlobResourceContents/image-file-contents.json @@ -0,0 +1,5 @@ +{ + "uri": "file:///example.png", + "mimeType": "image/png", + "blob": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/BooleanSchema/boolean-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/BooleanSchema/boolean-input-schema.json new file mode 100644 index 0000000000..48d6d589c1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/BooleanSchema/boolean-input-schema.json @@ -0,0 +1,6 @@ +{ + "type": "boolean", + "title": "Display Name", + "description": "Description text", + "default": false +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequest/call-tool-request.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequest/call-tool-request.json new file mode 100644 index 0000000000..2429aeca86 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequest/call-tool-request.json @@ -0,0 +1,19 @@ +{ + "jsonrpc": "2.0", + "id": "call-tool-example", + "method": "tools/call", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "name": "get_weather", + "arguments": { + "location": "New York" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/get-weather-tool-call-params.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/get-weather-tool-call-params.json new file mode 100644 index 0000000000..c65f9ceae8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/get-weather-tool-call-params.json @@ -0,0 +1,14 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "name": "get_weather", + "arguments": { + "location": "New York" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/tool-call-params-with-progress-token.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/tool-call-params-with-progress-token.json new file mode 100644 index 0000000000..8335d11a4e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/tool-call-params-with-progress-token.json @@ -0,0 +1,15 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {}, + "progressToken": "oivaizmir" + }, + "name": "build_simulation", + "arguments": { + "city": "Micropolis" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/invalid-tool-input-error.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/invalid-tool-input-error.json new file mode 100644 index 0000000000..59648895c2 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/invalid-tool-input-error.json @@ -0,0 +1,10 @@ +{ + "resultType": "complete", + "content": [ + { + "type": "text", + "text": "Invalid departure date: must be in the future. Current date is 08/08/2025." + } + ], + "isError": true +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-array-structured-content.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-array-structured-content.json new file mode 100644 index 0000000000..ccb136143b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-array-structured-content.json @@ -0,0 +1,13 @@ +{ + "resultType": "complete", + "content": [ + { + "type": "text", + "text": "Found 2 users: Alice (alice@example.com) and Bob (bob@example.com)." + } + ], + "structuredContent": [ + { "id": "1", "name": "Alice", "email": "alice@example.com" }, + { "id": "2", "name": "Bob", "email": "bob@example.com" } + ] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-structured-content.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-structured-content.json new file mode 100644 index 0000000000..b7a9cdb80f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-structured-content.json @@ -0,0 +1,14 @@ +{ + "resultType": "complete", + "content": [ + { + "type": "text", + "text": "{\"temperature\": 22.5, \"conditions\": \"Partly cloudy\", \"humidity\": 65}" + } + ], + "structuredContent": { + "temperature": 22.5, + "conditions": "Partly cloudy", + "humidity": 65 + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-unstructured-text.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-unstructured-text.json new file mode 100644 index 0000000000..4f54c48d0a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-unstructured-text.json @@ -0,0 +1,10 @@ +{ + "resultType": "complete", + "content": [ + { + "type": "text", + "text": "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy" + } + ], + "isError": false +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolResultResponse/call-tool-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResultResponse/call-tool-result-response.json new file mode 100644 index 0000000000..da4c062ca5 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResultResponse/call-tool-result-response.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": "call-tool-example", + "result": { + "resultType": "complete", + "content": [ + { + "type": "text", + "text": "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy" + } + ], + "isError": false + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotification/user-requested-cancellation.json b/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotification/user-requested-cancellation.json new file mode 100644 index 0000000000..aa52f8e4c5 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotification/user-requested-cancellation.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { + "requestId": "123", + "reason": "User requested cancellation" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotificationParams/user-requested-cancellation.json b/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotificationParams/user-requested-cancellation.json new file mode 100644 index 0000000000..fb032ac1b4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotificationParams/user-requested-cancellation.json @@ -0,0 +1,4 @@ +{ + "requestId": "123", + "reason": "User requested cancellation" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-and-url-mode-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-and-url-mode-support.json new file mode 100644 index 0000000000..ca75391d3b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-and-url-mode-support.json @@ -0,0 +1,6 @@ +{ + "elicitation": { + "form": {}, + "url": {} + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-only-implicit.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-only-implicit.json new file mode 100644 index 0000000000..29786c4c03 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-only-implicit.json @@ -0,0 +1,3 @@ +{ + "elicitation": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/extensions-ui-mime-types.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/extensions-ui-mime-types.json new file mode 100644 index 0000000000..449bf29f5b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/extensions-ui-mime-types.json @@ -0,0 +1,7 @@ +{ + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/roots-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/roots-minimum-baseline-support.json new file mode 100644 index 0000000000..87a706ee4d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/roots-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "roots": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-context-inclusion-support-deprecated.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-context-inclusion-support-deprecated.json new file mode 100644 index 0000000000..f6aba71c7b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-context-inclusion-support-deprecated.json @@ -0,0 +1,5 @@ +{ + "sampling": { + "context": {} + } +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-minimum-baseline-support.json new file mode 100644 index 0000000000..5448e67a33 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "sampling": {} +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-tool-use-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-tool-use-support.json new file mode 100644 index 0000000000..b269d8912c --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-tool-use-support.json @@ -0,0 +1,5 @@ +{ + "sampling": { + "tools": {} + } +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequest/completion-request.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequest/completion-request.json new file mode 100644 index 0000000000..f3c5a07417 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequest/completion-request.json @@ -0,0 +1,23 @@ +{ + "jsonrpc": "2.0", + "id": "completion-example", + "method": "completion/complete", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "ref": { + "type": "ref/prompt", + "name": "code_review" + }, + "argument": { + "name": "language", + "value": "py" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion-with-context.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion-with-context.json new file mode 100644 index 0000000000..fb0f637793 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion-with-context.json @@ -0,0 +1,23 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "ref": { + "type": "ref/prompt", + "name": "code_review" + }, + "argument": { + "name": "framework", + "value": "fla" + }, + "context": { + "arguments": { + "language": "python" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion.json new file mode 100644 index 0000000000..af2bf84a08 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion.json @@ -0,0 +1,18 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "ref": { + "type": "ref/prompt", + "name": "code_review" + }, + "argument": { + "name": "language", + "value": "py" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/multiple-completion-values-with-more-available.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/multiple-completion-values-with-more-available.json new file mode 100644 index 0000000000..c2f0633562 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/multiple-completion-values-with-more-available.json @@ -0,0 +1,8 @@ +{ + "resultType": "complete", + "completion": { + "values": ["python", "pytorch", "pyside"], + "total": 10, + "hasMore": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/single-completion-value.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/single-completion-value.json new file mode 100644 index 0000000000..36ec8985e5 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/single-completion-value.json @@ -0,0 +1,8 @@ +{ + "resultType": "complete", + "completion": { + "values": ["flask"], + "total": 1, + "hasMore": false + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteResultResponse/completion-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResultResponse/completion-result-response.json new file mode 100644 index 0000000000..fb7156e5fa --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResultResponse/completion-result-response.json @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "id": "completion-example", + "result": { + "resultType": "complete", + "completion": { + "values": ["flask"], + "total": 1, + "hasMore": false + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequest/sampling-request.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequest/sampling-request.json new file mode 100644 index 0000000000..70a17485f8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequest/sampling-request.json @@ -0,0 +1,25 @@ +{ + "method": "sampling/createMessage", + "params": { + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } + } + ], + "modelPreferences": { + "hints": [ + { + "name": "claude-3-sonnet" + } + ], + "intelligencePriority": 0.8, + "speedPriority": 0.5 + }, + "systemPrompt": "You are a helpful assistant.", + "maxTokens": 100 + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/basic-request.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/basic-request.json new file mode 100644 index 0000000000..1c88a3f35f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/basic-request.json @@ -0,0 +1,22 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } + } + ], + "modelPreferences": { + "hints": [ + { + "name": "claude-3-sonnet" + } + ], + "intelligencePriority": 0.8, + "speedPriority": 0.5 + }, + "systemPrompt": "You are a helpful assistant.", + "maxTokens": 100 +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/follow-up-with-tool-results.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/follow-up-with-tool-results.json new file mode 100644 index 0000000000..cdea2d858d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/follow-up-with-tool-results.json @@ -0,0 +1,67 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What's the weather like in Paris and London?" + } + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_abc123", + "name": "get_weather", + "input": { "city": "Paris" } + }, + { + "type": "tool_use", + "id": "call_def456", + "name": "get_weather", + "input": { "city": "London" } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "toolUseId": "call_abc123", + "content": [ + { + "type": "text", + "text": "Weather in Paris: 18°C, partly cloudy" + } + ] + }, + { + "type": "tool_result", + "toolUseId": "call_def456", + "content": [ + { + "type": "text", + "text": "Weather in London: 15°C, rainy" + } + ] + } + ] + } + ], + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "inputSchema": { + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"] + } + } + ], + "maxTokens": 1000 +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/request-with-tools.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/request-with-tools.json new file mode 100644 index 0000000000..f79fd26ac9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/request-with-tools.json @@ -0,0 +1,31 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What's the weather like in Paris and London?" + } + } + ], + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "City name" + } + }, + "required": ["city"] + } + } + ], + "toolChoice": { + "mode": "auto" + }, + "maxTokens": 1000 +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/final-response.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/final-response.json new file mode 100644 index 0000000000..a9a457a8a8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/final-response.json @@ -0,0 +1,9 @@ +{ + "role": "assistant", + "content": { + "type": "text", + "text": "Based on the current weather data:\n\n- **Paris**: 18°C and partly cloudy - quite pleasant!\n- **London**: 15°C and rainy - you'll want an umbrella.\n\nParis has slightly warmer and drier conditions today." + }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/text-response.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/text-response.json new file mode 100644 index 0000000000..3b6f18dc7b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/text-response.json @@ -0,0 +1,9 @@ +{ + "role": "assistant", + "content": { + "type": "text", + "text": "The capital of France is Paris." + }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/tool-use-response.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/tool-use-response.json new file mode 100644 index 0000000000..7599eee178 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/tool-use-response.json @@ -0,0 +1,23 @@ +{ + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_abc123", + "name": "get_weather", + "input": { + "city": "Paris" + } + }, + { + "type": "tool_use", + "id": "call_def456", + "name": "get_weather", + "input": { + "city": "London" + } + } + ], + "model": "claude-3-sonnet-20240307", + "stopReason": "toolUse" +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/DiscoverRequest/server-discover-request.json b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverRequest/server-discover-request.json new file mode 100644 index 0000000000..85c7fe80f1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverRequest/server-discover-request.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "discover-1", + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json new file mode 100644 index 0000000000..9f636318c4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json @@ -0,0 +1,15 @@ +{ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": { + "tools": {}, + "resources": {} + }, + "serverInfo": { + "name": "ExampleServer", + "version": "1.0.0" + }, + "instructions": "This server provides weather and resource utilities. Prefer `get_weather` for forecast lookups.", + "ttlMs": 3600000, + "cacheScope": "public" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json new file mode 100644 index 0000000000..1a162891bb --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json @@ -0,0 +1,18 @@ +{ + "jsonrpc": "2.0", + "id": "discover-1", + "result": { + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": { + "tools": {}, + "resources": {} + }, + "serverInfo": { + "name": "ExampleServer", + "version": "1.0.0" + }, + "ttlMs": 3600000, + "cacheScope": "public" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequest/elicitation-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequest/elicitation-request.json new file mode 100644 index 0000000000..7c356f3556 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequest/elicitation-request.json @@ -0,0 +1,18 @@ +{ + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "GitHub Username", + "description": "Your GitHub username" + } + }, + "required": ["name"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-multiple-fields.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-multiple-fields.json new file mode 100644 index 0000000000..7b8e0557c6 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-multiple-fields.json @@ -0,0 +1,24 @@ +{ + "mode": "form", + "message": "Please provide your contact information", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Your full name" + }, + "email": { + "type": "string", + "format": "email", + "description": "Your email address" + }, + "age": { + "type": "number", + "minimum": 18, + "description": "Your age" + } + }, + "required": ["name", "email"] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-single-field.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-single-field.json new file mode 100644 index 0000000000..ea8fb43f64 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-single-field.json @@ -0,0 +1,13 @@ +{ + "mode": "form", + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json new file mode 100644 index 0000000000..cf791ee3fe --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json @@ -0,0 +1,6 @@ +{ + "mode": "url", + "elicitationId": "550e8400-e29b-41d4-a716-446655440000", + "url": "https://mcp.example.com/ui/set_api_key", + "message": "Please provide your API key to continue." +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/accept-url-mode-no-content.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/accept-url-mode-no-content.json new file mode 100644 index 0000000000..ab47af78f3 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/accept-url-mode-no-content.json @@ -0,0 +1,3 @@ +{ + "action": "accept" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-multiple-fields.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-multiple-fields.json new file mode 100644 index 0000000000..99b18e1990 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-multiple-fields.json @@ -0,0 +1,8 @@ +{ + "action": "accept", + "content": { + "name": "Monalisa Octocat", + "email": "octocat@github.com", + "age": 30 + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-single-field.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-single-field.json new file mode 100644 index 0000000000..4798da663e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-single-field.json @@ -0,0 +1,6 @@ +{ + "action": "accept", + "content": { + "name": "octocat" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json new file mode 100644 index 0000000000..bb6d564585 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/elicitation/complete", + "params": { + "elicitationId": "550e8400-e29b-41d4-a716-446655440000" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/EmbeddedResource/embedded-file-resource-with-annotations.json b/packages/core/test/corpus/fixtures/2026-07-28/EmbeddedResource/embedded-file-resource-with-annotations.json new file mode 100644 index 0000000000..01a8f2eb9f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/EmbeddedResource/embedded-file-resource-with-annotations.json @@ -0,0 +1,13 @@ +{ + "type": "resource", + "resource": { + "uri": "file:///project/src/main.rs", + "mimeType": "text/x-rust", + "text": "fn main() {\n println!(\"Hello world!\");\n}" + }, + "annotations": { + "audience": ["user", "assistant"], + "priority": 0.7, + "lastModified": "2025-05-03T14:30:00Z" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequest/get-prompt-request.json b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequest/get-prompt-request.json new file mode 100644 index 0000000000..b8af17c98d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequest/get-prompt-request.json @@ -0,0 +1,19 @@ +{ + "jsonrpc": "2.0", + "id": "get-prompt-example", + "method": "prompts/get", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "name": "code_review", + "arguments": { + "code": "def hello():\n print('world')" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequestParams/get-code-review-prompt.json b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequestParams/get-code-review-prompt.json new file mode 100644 index 0000000000..0bfdf3b01b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequestParams/get-code-review-prompt.json @@ -0,0 +1,14 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "name": "code_review", + "arguments": { + "code": "def hello():\n print('world')" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResult/code-review-prompt.json b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResult/code-review-prompt.json new file mode 100644 index 0000000000..cb3518aee4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResult/code-review-prompt.json @@ -0,0 +1,13 @@ +{ + "resultType": "complete", + "description": "Code review prompt", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this Python code:\ndef hello():\n print('world')" + } + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResultResponse/get-prompt-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResultResponse/get-prompt-result-response.json new file mode 100644 index 0000000000..a257ccf9ed --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResultResponse/get-prompt-result-response.json @@ -0,0 +1,17 @@ +{ + "jsonrpc": "2.0", + "id": "get-prompt-example", + "result": { + "resultType": "complete", + "description": "Code review prompt", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this Python code:\ndef hello():\n print('world')" + } + } + ] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ImageContent/image-png-content-with-annotations.json b/packages/core/test/corpus/fixtures/2026-07-28/ImageContent/image-png-content-with-annotations.json new file mode 100644 index 0000000000..32f8ef683e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ImageContent/image-png-content-with-annotations.json @@ -0,0 +1,9 @@ +{ + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "mimeType": "image/png", + "annotations": { + "audience": ["user"], + "priority": 0.9 + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InputRequests/elicitation-and-sampling-input-requests.json b/packages/core/test/corpus/fixtures/2026-07-28/InputRequests/elicitation-and-sampling-input-requests.json new file mode 100644 index 0000000000..5d1ce974aa --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InputRequests/elicitation-and-sampling-input-requests.json @@ -0,0 +1,33 @@ +{ + "github_login": { + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + } + }, + "capital_of_france": { + "method": "sampling/createMessage", + "params": { + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } + } + ], + "maxTokens": 100 + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-elicitation-and-sampling-and-request-state.json b/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-elicitation-and-sampling-and-request-state.json new file mode 100644 index 0000000000..6ffc953944 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-elicitation-and-sampling-and-request-state.json @@ -0,0 +1,36 @@ +{ + "resultType": "input_required", + "inputRequests": { + "github_login": { + "method": "elicitation/create", + "params": { + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + } + }, + "capital_of_france": { + "method": "sampling/createMessage", + "params": { + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } + } + ], + "maxTokens": 100 + } + } + }, + "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-request-state-only.json b/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-request-state-only.json new file mode 100644 index 0000000000..7f1dec1f69 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-request-state-only.json @@ -0,0 +1,4 @@ +{ + "resultType": "input_required", + "requestState": "eyJwcm9ncmVzcyI6IjUwJSIsInN0YXRlIjoicHJvY2Vzc2luZyJ9" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InputResponses/elicitation-and-sampling-input-responses.json b/packages/core/test/corpus/fixtures/2026-07-28/InputResponses/elicitation-and-sampling-input-responses.json new file mode 100644 index 0000000000..1f5cbcf0d6 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InputResponses/elicitation-and-sampling-input-responses.json @@ -0,0 +1,17 @@ +{ + "github_login": { + "action": "accept", + "content": { + "name": "octocat" + } + }, + "capital_of_france": { + "role": "assistant", + "content": { + "type": "text", + "text": "The capital of France is Paris." + }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InternalError/unexpected-error.json b/packages/core/test/corpus/fixtures/2026-07-28/InternalError/unexpected-error.json new file mode 100644 index 0000000000..2560c88d32 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InternalError/unexpected-error.json @@ -0,0 +1,4 @@ +{ + "code": -32603, + "message": "Internal error" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-cursor.json b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-cursor.json new file mode 100644 index 0000000000..674bb5422d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-cursor.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Invalid cursor" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-tool-arguments.json b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-tool-arguments.json new file mode 100644 index 0000000000..afa93c8b4a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-tool-arguments.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Invalid arguments for tool calculate: Missing required property 'expression'" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-prompt.json b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-prompt.json new file mode 100644 index 0000000000..741e88b0d7 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-prompt.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Unknown prompt: invalid_prompt_name" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-tool.json b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-tool.json new file mode 100644 index 0000000000..bde98fa520 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-tool.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Unknown tool: invalid_tool_name" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsRequest/list-prompts-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsRequest/list-prompts-request.json new file mode 100644 index 0000000000..9fa881f332 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsRequest/list-prompts-request.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "list-prompts-example", + "method": "prompts/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResult/prompts-list-with-cursor-and-ttl.json b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResult/prompts-list-with-cursor-and-ttl.json new file mode 100644 index 0000000000..1d841baa83 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResult/prompts-list-with-cursor-and-ttl.json @@ -0,0 +1,27 @@ +{ + "resultType": "complete", + "prompts": [ + { + "name": "code_review", + "title": "Request Code Review", + "description": "Asks the LLM to analyze code quality and suggest improvements", + "arguments": [ + { + "name": "code", + "description": "The code to review", + "required": true + } + ], + "icons": [ + { + "src": "https://example.com/review-icon.svg", + "mimeType": "image/svg+xml", + "sizes": ["any"] + } + ] + } + ], + "nextCursor": "next-page-cursor", + "ttlMs": 600000, + "cacheScope": "public" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResultResponse/list-prompts-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResultResponse/list-prompts-result-response.json new file mode 100644 index 0000000000..61118cab64 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResultResponse/list-prompts-result-response.json @@ -0,0 +1,31 @@ +{ + "jsonrpc": "2.0", + "id": "list-prompts-example", + "result": { + "resultType": "complete", + "prompts": [ + { + "name": "code_review", + "title": "Request Code Review", + "description": "Asks the LLM to analyze code quality and suggest improvements", + "arguments": [ + { + "name": "code", + "description": "The code to review", + "required": true + } + ], + "icons": [ + { + "src": "https://example.com/review-icon.svg", + "mimeType": "image/svg+xml", + "sizes": ["any"] + } + ] + } + ], + "nextCursor": "next-page-cursor", + "ttlMs": 600000, + "cacheScope": "public" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesRequest/list-resource-templates-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesRequest/list-resource-templates-request.json new file mode 100644 index 0000000000..13917c5911 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesRequest/list-resource-templates-request.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "list-resource-templates-example", + "method": "resources/templates/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResult/resource-templates-list-with-cursor-and-ttl.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResult/resource-templates-list-with-cursor-and-ttl.json new file mode 100644 index 0000000000..7abd62b150 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResult/resource-templates-list-with-cursor-and-ttl.json @@ -0,0 +1,22 @@ +{ + "resultType": "complete", + "resourceTemplates": [ + { + "uriTemplate": "file:///{path}", + "name": "Project Files", + "title": "📁 Project Files", + "description": "Access files in the project directory", + "mimeType": "application/octet-stream", + "icons": [ + { + "src": "https://example.com/folder-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "nextCursor": "next-page-cursor", + "ttlMs": 3600000, + "cacheScope": "public" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResultResponse/list-resource-templates-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResultResponse/list-resource-templates-result-response.json new file mode 100644 index 0000000000..3fda957c35 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResultResponse/list-resource-templates-result-response.json @@ -0,0 +1,25 @@ +{ + "jsonrpc": "2.0", + "id": "list-resource-templates-example", + "result": { + "resultType": "complete", + "resourceTemplates": [ + { + "uriTemplate": "file:///{path}", + "name": "Project Files", + "title": "Project Files", + "description": "Access files in the project directory", + "mimeType": "application/octet-stream", + "icons": [ + { + "src": "https://example.com/folder-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "ttlMs": 3600000, + "cacheScope": "public" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesRequest/list-resources-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesRequest/list-resources-request.json new file mode 100644 index 0000000000..0ce2f47025 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesRequest/list-resources-request.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "list-resources-example", + "method": "resources/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResult/resources-list-with-cursor-and-ttl.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResult/resources-list-with-cursor-and-ttl.json new file mode 100644 index 0000000000..e701fc6421 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResult/resources-list-with-cursor-and-ttl.json @@ -0,0 +1,22 @@ +{ + "resultType": "complete", + "resources": [ + { + "uri": "file:///project/src/main.rs", + "name": "main.rs", + "title": "Rust Software Application Main File", + "description": "Primary application entry point", + "mimeType": "text/x-rust", + "icons": [ + { + "src": "https://example.com/rust-file-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "nextCursor": "eyJwYWdlIjogM30=", + "ttlMs": 600000, + "cacheScope": "private" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResultResponse/list-resources-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResultResponse/list-resources-result-response.json new file mode 100644 index 0000000000..e17cc50111 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResultResponse/list-resources-result-response.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "id": "list-resources-example", + "result": { + "resultType": "complete", + "resources": [ + { + "uri": "file:///project/src/main.rs", + "name": "main.rs", + "title": "Rust Software Application Main File", + "description": "Primary application entry point", + "mimeType": "text/x-rust", + "icons": [ + { + "src": "https://example.com/rust-file-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "nextCursor": "eyJwYWdlIjogM30=", + "ttlMs": 600000, + "cacheScope": "private" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListRootsRequest/list-roots-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsRequest/list-roots-request.json new file mode 100644 index 0000000000..ef0b0c0c6a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsRequest/list-roots-request.json @@ -0,0 +1,4 @@ +{ + "id": "list-roots-example", + "method": "roots/list" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/multiple-root-directories.json b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/multiple-root-directories.json new file mode 100644 index 0000000000..0cf0e78c2d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/multiple-root-directories.json @@ -0,0 +1,12 @@ +{ + "roots": [ + { + "uri": "file:///home/user/repos/frontend", + "name": "Frontend Repository" + }, + { + "uri": "file:///home/user/repos/backend", + "name": "Backend Repository" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/single-root-directory.json b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/single-root-directory.json new file mode 100644 index 0000000000..0ea6963dcd --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/single-root-directory.json @@ -0,0 +1,8 @@ +{ + "roots": [ + { + "uri": "file:///home/user/projects/myproject", + "name": "My Project" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListToolsRequest/list-tools-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsRequest/list-tools-request.json new file mode 100644 index 0000000000..02e93eb771 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsRequest/list-tools-request.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "list-tools-example", + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResult/tools-list-with-cursor-and-ttl.json b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResult/tools-list-with-cursor-and-ttl.json new file mode 100644 index 0000000000..b81f02d4c9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResult/tools-list-with-cursor-and-ttl.json @@ -0,0 +1,30 @@ +{ + "resultType": "complete", + "tools": [ + { + "name": "get_weather", + "title": "Weather Information Provider", + "description": "Get current weather information for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City name or zip code" + } + }, + "required": ["location"] + }, + "icons": [ + { + "src": "https://example.com/weather-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "nextCursor": "next-page-cursor", + "ttlMs": 300000, + "cacheScope": "public" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResultResponse/list-tools-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResultResponse/list-tools-result-response.json new file mode 100644 index 0000000000..1e0c84f9ef --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResultResponse/list-tools-result-response.json @@ -0,0 +1,34 @@ +{ + "jsonrpc": "2.0", + "id": "list-tools-example", + "result": { + "resultType": "complete", + "tools": [ + { + "name": "get_weather", + "title": "Weather Information Provider", + "description": "Get current weather information for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City name or zip code" + } + }, + "required": ["location"] + }, + "icons": [ + { + "src": "https://example.com/weather-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "nextCursor": "next-page-cursor", + "ttlMs": 3600000, + "cacheScope": "public" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotification/log-database-connection-failed.json b/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotification/log-database-connection-failed.json new file mode 100644 index 0000000000..7c131e04bb --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotification/log-database-connection-failed.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/message", + "params": { + "level": "error", + "logger": "database", + "data": { + "error": "Connection failed", + "details": { + "host": "localhost", + "port": 5432 + } + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotificationParams/log-database-connection-failed.json b/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotificationParams/log-database-connection-failed.json new file mode 100644 index 0000000000..dad2430eec --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotificationParams/log-database-connection-failed.json @@ -0,0 +1,11 @@ +{ + "level": "error", + "logger": "database", + "data": { + "error": "Connection failed", + "details": { + "host": "localhost", + "port": 5432 + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/MethodNotFoundError/prompts-not-supported.json b/packages/core/test/corpus/fixtures/2026-07-28/MethodNotFoundError/prompts-not-supported.json new file mode 100644 index 0000000000..0a025a1cd1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/MethodNotFoundError/prompts-not-supported.json @@ -0,0 +1,7 @@ +{ + "code": -32601, + "message": "Prompts not supported", + "data": { + "reason": "Server does not support the prompts capability" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/MissingRequiredClientCapabilityError/missing-elicitation-capability.json b/packages/core/test/corpus/fixtures/2026-07-28/MissingRequiredClientCapabilityError/missing-elicitation-capability.json new file mode 100644 index 0000000000..10917d3603 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/MissingRequiredClientCapabilityError/missing-elicitation-capability.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32003, + "message": "Server requires the elicitation capability for this request", + "data": { + "requiredCapabilities": { + "elicitation": {} + } + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ModelPreferences/with-hints-and-priorities.json b/packages/core/test/corpus/fixtures/2026-07-28/ModelPreferences/with-hints-and-priorities.json new file mode 100644 index 0000000000..44786871db --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ModelPreferences/with-hints-and-priorities.json @@ -0,0 +1,9 @@ +{ + "hints": [ + { "name": "claude-3-sonnet" }, + { "name": "claude" } + ], + "costPriority": 0.3, + "speedPriority": 0.8, + "intelligencePriority": 0.5 +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/NumberSchema/number-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/NumberSchema/number-input-schema.json new file mode 100644 index 0000000000..6049ed6636 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/NumberSchema/number-input-schema.json @@ -0,0 +1,8 @@ +{ + "type": "number", + "title": "Display Name", + "description": "Description text", + "minimum": 0, + "maximum": 100, + "default": 50 +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/PaginatedRequestParams/list-with-cursor.json b/packages/core/test/corpus/fixtures/2026-07-28/PaginatedRequestParams/list-with-cursor.json new file mode 100644 index 0000000000..948178be8d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/PaginatedRequestParams/list-with-cursor.json @@ -0,0 +1,11 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "cursor": "eyJwYWdlIjogMn0=" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ParseError/invalid-json.json b/packages/core/test/corpus/fixtures/2026-07-28/ParseError/invalid-json.json new file mode 100644 index 0000000000..eb47719580 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ParseError/invalid-json.json @@ -0,0 +1,4 @@ +{ + "code": -32700, + "message": "Parse error: Invalid JSON" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotification/progress-message.json b/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotification/progress-message.json new file mode 100644 index 0000000000..1e66088b23 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotification/progress-message.json @@ -0,0 +1,10 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": { + "progressToken": "oivaizmir", + "progress": 50, + "total": 100, + "message": "Reticulating splines..." + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotificationParams/progress-message.json b/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotificationParams/progress-message.json new file mode 100644 index 0000000000..49549c115f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotificationParams/progress-message.json @@ -0,0 +1,6 @@ +{ + "progressToken": "oivaizmir", + "progress": 50, + "total": 100, + "message": "Reticulating splines..." +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/PromptListChangedNotification/prompts-list-changed.json b/packages/core/test/corpus/fixtures/2026-07-28/PromptListChangedNotification/prompts-list-changed.json new file mode 100644 index 0000000000..858cd5d874 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/PromptListChangedNotification/prompts-list-changed.json @@ -0,0 +1,4 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/prompts/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceRequest/read-resource-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceRequest/read-resource-request.json new file mode 100644 index 0000000000..073a816eb6 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceRequest/read-resource-request.json @@ -0,0 +1,16 @@ +{ + "jsonrpc": "2.0", + "id": "read-resource-example", + "method": "resources/read", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResult/file-resource-contents.json b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResult/file-resource-contents.json new file mode 100644 index 0000000000..591fd09ce9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResult/file-resource-contents.json @@ -0,0 +1,12 @@ +{ + "resultType": "complete", + "contents": [ + { + "uri": "file:///project/src/main.rs", + "mimeType": "text/x-rust", + "text": "fn main() {\n println!(\"Hello world!\");\n}" + } + ], + "ttlMs": 60000, + "cacheScope": "private" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response-with-ttl.json b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response-with-ttl.json new file mode 100644 index 0000000000..b63f398a16 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response-with-ttl.json @@ -0,0 +1,16 @@ +{ + "jsonrpc": "2.0", + "id": "read-resource-with-ttl-example", + "result": { + "resultType": "complete", + "contents": [ + { + "uri": "file:///project/src/main.rs", + "mimeType": "text/x-rust", + "text": "fn main() {\n println!(\"Hello world!\");\n}" + } + ], + "ttlMs": 60000, + "cacheScope": "private" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response.json new file mode 100644 index 0000000000..93bfae6943 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": "read-resource-example", + "result": { + "resultType": "complete", + "contents": [ + { + "uri": "file:///project/src/main.rs", + "mimeType": "text/x-rust", + "text": "fn main() {\n println!(\"Hello world!\");\n}" + } + ] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Resource/file-resource-with-annotations.json b/packages/core/test/corpus/fixtures/2026-07-28/Resource/file-resource-with-annotations.json new file mode 100644 index 0000000000..3e268afb1d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Resource/file-resource-with-annotations.json @@ -0,0 +1,11 @@ +{ + "uri": "file:///project/README.md", + "name": "README.md", + "title": "Project Documentation", + "mimeType": "text/markdown", + "annotations": { + "audience": ["user"], + "priority": 0.8, + "lastModified": "2025-01-12T15:00:58Z" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ResourceLink/file-resource-link.json b/packages/core/test/corpus/fixtures/2026-07-28/ResourceLink/file-resource-link.json new file mode 100644 index 0000000000..d35682596f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ResourceLink/file-resource-link.json @@ -0,0 +1,7 @@ +{ + "type": "resource_link", + "uri": "file:///project/src/main.rs", + "name": "main.rs", + "description": "Primary application entry point", + "mimeType": "text/x-rust" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ResourceListChangedNotification/resources-list-changed.json b/packages/core/test/corpus/fixtures/2026-07-28/ResourceListChangedNotification/resources-list-changed.json new file mode 100644 index 0000000000..6ba5e168ec --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ResourceListChangedNotification/resources-list-changed.json @@ -0,0 +1,4 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/resources/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotification/file-resource-updated-notification.json b/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotification/file-resource-updated-notification.json new file mode 100644 index 0000000000..b5f9ef67f7 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotification/file-resource-updated-notification.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/resources/updated", + "params": { + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotificationParams/file-resource-updated.json b/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotificationParams/file-resource-updated.json new file mode 100644 index 0000000000..10decf86a2 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotificationParams/file-resource-updated.json @@ -0,0 +1,3 @@ +{ + "uri": "file:///project/src/main.rs" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Root/project-directory.json b/packages/core/test/corpus/fixtures/2026-07-28/Root/project-directory.json new file mode 100644 index 0000000000..b3195b3d74 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Root/project-directory.json @@ -0,0 +1,4 @@ +{ + "uri": "file:///home/user/projects/myproject", + "name": "My Project" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/multiple-content-blocks.json b/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/multiple-content-blocks.json new file mode 100644 index 0000000000..9190b9f16d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/multiple-content-blocks.json @@ -0,0 +1,15 @@ +{ + "role": "user", + "content": [ + { + "type": "tool_result", + "toolUseId": "call_123", + "content": [{ "type": "text", "text": "Result 1" }] + }, + { + "type": "tool_result", + "toolUseId": "call_456", + "content": [{ "type": "text", "text": "Result 2" }] + } + ] +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/single-content-block.json b/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/single-content-block.json new file mode 100644 index 0000000000..5aaa0f15c3 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/single-content-block.json @@ -0,0 +1,7 @@ +{ + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/completions-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/completions-minimum-baseline-support.json new file mode 100644 index 0000000000..b151d2b774 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/completions-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "completions": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/extensions-tasks.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/extensions-tasks.json new file mode 100644 index 0000000000..10ed90d38d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/extensions-tasks.json @@ -0,0 +1,5 @@ +{ + "extensions": { + "io.modelcontextprotocol/tasks": {} + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/logging-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/logging-minimum-baseline-support.json new file mode 100644 index 0000000000..6be7397886 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/logging-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "logging": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-list-changed-notifications.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-list-changed-notifications.json new file mode 100644 index 0000000000..0fcacf6154 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-list-changed-notifications.json @@ -0,0 +1,5 @@ +{ + "prompts": { + "listChanged": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-minimum-baseline-support.json new file mode 100644 index 0000000000..03b9366156 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "prompts": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-all-notifications.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-all-notifications.json new file mode 100644 index 0000000000..52bc7897e9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-all-notifications.json @@ -0,0 +1,6 @@ +{ + "resources": { + "subscribe": true, + "listChanged": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-list-changed-notifications-only.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-list-changed-notifications-only.json new file mode 100644 index 0000000000..0b144588c1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-list-changed-notifications-only.json @@ -0,0 +1,5 @@ +{ + "resources": { + "listChanged": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-minimum-baseline-support.json new file mode 100644 index 0000000000..d6eebc58e8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "resources": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-subscription-to-individual-resource-updates-only.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-subscription-to-individual-resource-updates-only.json new file mode 100644 index 0000000000..0ec9700ab9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-subscription-to-individual-resource-updates-only.json @@ -0,0 +1,5 @@ +{ + "resources": { + "subscribe": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-list-changed-notifications.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-list-changed-notifications.json new file mode 100644 index 0000000000..73851b6c5c --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-list-changed-notifications.json @@ -0,0 +1,5 @@ +{ + "tools": { + "listChanged": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-minimum-baseline-support.json new file mode 100644 index 0000000000..2f8e00f819 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "tools": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/StringSchema/email-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/StringSchema/email-input-schema.json new file mode 100644 index 0000000000..8d85641332 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/StringSchema/email-input-schema.json @@ -0,0 +1,9 @@ +{ + "type": "string", + "title": "Display Name", + "description": "Description text", + "minLength": 3, + "maxLength": 50, + "format": "email", + "default": "user@example.com" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsAcknowledgedNotification/listen-acknowledged.json b/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsAcknowledgedNotification/listen-acknowledged.json new file mode 100644 index 0000000000..d3e444f9e6 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsAcknowledgedNotification/listen-acknowledged.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/subscriptions/acknowledged", + "params": { + "_meta": { + "io.modelcontextprotocol/subscriptionId": "listen-1" + }, + "notifications": { + "toolsListChanged": true, + "resourceSubscriptions": ["file:///project/config.json"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsListenRequest/listen-for-list-changes.json b/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsListenRequest/listen-for-list-changes.json new file mode 100644 index 0000000000..76858b497e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsListenRequest/listen-for-list-changes.json @@ -0,0 +1,19 @@ +{ + "jsonrpc": "2.0", + "id": "listen-1", + "method": "subscriptions/listen", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "notifications": { + "toolsListChanged": true, + "resourceSubscriptions": ["file:///project/config.json"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/TextContent/text-content.json b/packages/core/test/corpus/fixtures/2026-07-28/TextContent/text-content.json new file mode 100644 index 0000000000..13df577040 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/TextContent/text-content.json @@ -0,0 +1,4 @@ +{ + "type": "text", + "text": "Tool result text" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/TextResourceContents/text-file-contents.json b/packages/core/test/corpus/fixtures/2026-07-28/TextResourceContents/text-file-contents.json new file mode 100644 index 0000000000..a70f268592 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/TextResourceContents/text-file-contents.json @@ -0,0 +1,5 @@ +{ + "uri": "file:///example.txt", + "mimeType": "text/plain", + "text": "Resource content" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/TitledMultiSelectEnumSchema/titled-color-multi-select-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/TitledMultiSelectEnumSchema/titled-color-multi-select-schema.json new file mode 100644 index 0000000000..e6b9e6f8a0 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/TitledMultiSelectEnumSchema/titled-color-multi-select-schema.json @@ -0,0 +1,15 @@ +{ + "type": "array", + "title": "Color Selection", + "description": "Choose your favorite colors", + "minItems": 1, + "maxItems": 2, + "items": { + "anyOf": [ + { "const": "#FF0000", "title": "Red" }, + { "const": "#00FF00", "title": "Green" }, + { "const": "#0000FF", "title": "Blue" } + ] + }, + "default": ["#FF0000", "#00FF00"] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/TitledSingleSelectEnumSchema/titled-color-select-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/TitledSingleSelectEnumSchema/titled-color-select-schema.json new file mode 100644 index 0000000000..d1a4689195 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/TitledSingleSelectEnumSchema/titled-color-select-schema.json @@ -0,0 +1,11 @@ +{ + "type": "string", + "title": "Color Selection", + "description": "Choose your favorite color", + "oneOf": [ + { "const": "#FF0000", "title": "Red" }, + { "const": "#00FF00", "title": "Green" }, + { "const": "#0000FF", "title": "Blue" } + ], + "default": "#FF0000" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-array-output-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-array-output-schema.json new file mode 100644 index 0000000000..8c7edec623 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-array-output-schema.json @@ -0,0 +1,30 @@ +{ + "name": "list_users", + "title": "User List", + "description": "Returns a list of all users", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "User ID" + }, + "name": { + "type": "string", + "description": "User name" + }, + "email": { + "type": "string", + "description": "User email" + } + }, + "required": ["id", "name", "email"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-composition-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-composition-input-schema.json new file mode 100644 index 0000000000..7c7253af9f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-composition-input-schema.json @@ -0,0 +1,28 @@ +{ + "name": "find_resource", + "title": "Resource Finder", + "description": "Find a resource by ID or name", + "inputSchema": { + "type": "object", + "oneOf": [ + { + "properties": { + "id": { + "type": "string", + "description": "Resource ID" + } + }, + "required": ["id"] + }, + { + "properties": { + "name": { + "type": "string", + "description": "Resource name" + } + }, + "required": ["name"] + } + ] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-default-2020-12-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-default-2020-12-input-schema.json new file mode 100644 index 0000000000..d79a00eeaa --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-default-2020-12-input-schema.json @@ -0,0 +1,12 @@ +{ + "name": "calculate_sum", + "description": "Add two numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": { "type": "number" }, + "b": { "type": "number" } + }, + "required": ["a", "b"] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-explicit-draft-07-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-explicit-draft-07-input-schema.json new file mode 100644 index 0000000000..698d95b865 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-explicit-draft-07-input-schema.json @@ -0,0 +1,13 @@ +{ + "name": "calculate_sum", + "description": "Add two numbers", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "a": { "type": "number" }, + "b": { "type": "number" } + }, + "required": ["a", "b"] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-no-parameters.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-no-parameters.json new file mode 100644 index 0000000000..04a3a4e956 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-no-parameters.json @@ -0,0 +1,8 @@ +{ + "name": "get_current_time", + "description": "Returns the current server time", + "inputSchema": { + "type": "object", + "additionalProperties": false + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-output-schema-for-structured-content.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-output-schema-for-structured-content.json new file mode 100644 index 0000000000..a146983424 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-output-schema-for-structured-content.json @@ -0,0 +1,33 @@ +{ + "name": "get_weather_data", + "title": "Weather Data Retriever", + "description": "Get current weather data for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City name or zip code" + } + }, + "required": ["location"] + }, + "outputSchema": { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature in celsius" + }, + "conditions": { + "type": "string", + "description": "Weather conditions description" + }, + "humidity": { + "type": "number", + "description": "Humidity percentage" + } + }, + "required": ["temperature", "conditions", "humidity"] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ToolListChangedNotification/tools-list-changed.json b/packages/core/test/corpus/fixtures/2026-07-28/ToolListChangedNotification/tools-list-changed.json new file mode 100644 index 0000000000..a28e846763 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ToolListChangedNotification/tools-list-changed.json @@ -0,0 +1,4 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/tools/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ToolResultContent/get-weather-tool-result.json b/packages/core/test/corpus/fixtures/2026-07-28/ToolResultContent/get-weather-tool-result.json new file mode 100644 index 0000000000..3b44156d61 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ToolResultContent/get-weather-tool-result.json @@ -0,0 +1,10 @@ +{ + "type": "tool_result", + "toolUseId": "call_abc123", + "content": [ + { + "type": "text", + "text": "Weather in Paris: 18°C, partly cloudy" + } + ] +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ToolUseContent/get-weather-tool-use.json b/packages/core/test/corpus/fixtures/2026-07-28/ToolUseContent/get-weather-tool-use.json new file mode 100644 index 0000000000..197560de67 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ToolUseContent/get-weather-tool-use.json @@ -0,0 +1,8 @@ +{ + "type": "tool_use", + "id": "call_abc123", + "name": "get_weather", + "input": { + "city": "Paris" + } +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/UnsupportedProtocolVersionError/unsupported-version.json b/packages/core/test/corpus/fixtures/2026-07-28/UnsupportedProtocolVersionError/unsupported-version.json new file mode 100644 index 0000000000..d4c99b7ce8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/UnsupportedProtocolVersionError/unsupported-version.json @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32004, + "message": "Unsupported protocol version", + "data": { + "supported": ["2026-07-28", "2025-11-25"], + "requested": "1900-01-01" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/UntitledMultiSelectEnumSchema/color-multi-select-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/UntitledMultiSelectEnumSchema/color-multi-select-schema.json new file mode 100644 index 0000000000..d63467e7ee --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/UntitledMultiSelectEnumSchema/color-multi-select-schema.json @@ -0,0 +1,12 @@ +{ + "type": "array", + "title": "Color Selection", + "description": "Choose your favorite colors", + "minItems": 1, + "maxItems": 2, + "items": { + "type": "string", + "enum": ["Red", "Green", "Blue"] + }, + "default": ["Red", "Green"] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/UntitledSingleSelectEnumSchema/color-select-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/UntitledSingleSelectEnumSchema/color-select-schema.json new file mode 100644 index 0000000000..13e05d5789 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/UntitledSingleSelectEnumSchema/color-select-schema.json @@ -0,0 +1,7 @@ +{ + "type": "string", + "title": "Color Selection", + "description": "Choose your favorite color", + "enum": ["Red", "Green", "Blue"], + "default": "Red" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/manifest.json b/packages/core/test/corpus/fixtures/2026-07-28/manifest.json new file mode 100644 index 0000000000..8aa8155edd --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/manifest.json @@ -0,0 +1,312 @@ +{ + "revision": "2026-07-28", + "source": { + "repo": "modelcontextprotocol/modelcontextprotocol", + "path": "schema/draft/examples", + "commit": "0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65" + }, + "regenerate": "pnpm fetch:spec-examples --spec-dir # or [sha] to fetch from GitHub", + "directoryCount": 86, + "fileCount": 127, + "directories": { + "AudioContent": [ + "audio-wav-content.json" + ], + "BlobResourceContents": [ + "image-file-contents.json" + ], + "BooleanSchema": [ + "boolean-input-schema.json" + ], + "CallToolRequest": [ + "call-tool-request.json" + ], + "CallToolRequestParams": [ + "get-weather-tool-call-params.json", + "tool-call-params-with-progress-token.json" + ], + "CallToolResult": [ + "invalid-tool-input-error.json", + "result-with-array-structured-content.json", + "result-with-structured-content.json", + "result-with-unstructured-text.json" + ], + "CallToolResultResponse": [ + "call-tool-result-response.json" + ], + "CancelledNotification": [ + "user-requested-cancellation.json" + ], + "CancelledNotificationParams": [ + "user-requested-cancellation.json" + ], + "ClientCapabilities": [ + "elicitation-form-and-url-mode-support.json", + "elicitation-form-only-implicit.json", + "extensions-ui-mime-types.json", + "roots-minimum-baseline-support.json", + "sampling-context-inclusion-support-deprecated.json", + "sampling-minimum-baseline-support.json", + "sampling-tool-use-support.json" + ], + "CompleteRequest": [ + "completion-request.json" + ], + "CompleteRequestParams": [ + "prompt-argument-completion-with-context.json", + "prompt-argument-completion.json" + ], + "CompleteResult": [ + "multiple-completion-values-with-more-available.json", + "single-completion-value.json" + ], + "CompleteResultResponse": [ + "completion-result-response.json" + ], + "CreateMessageRequest": [ + "sampling-request.json" + ], + "CreateMessageRequestParams": [ + "basic-request.json", + "follow-up-with-tool-results.json", + "request-with-tools.json" + ], + "CreateMessageResult": [ + "final-response.json", + "text-response.json", + "tool-use-response.json" + ], + "DiscoverRequest": [ + "server-discover-request.json" + ], + "DiscoverResult": [ + "server-capabilities-discovery.json" + ], + "DiscoverResultResponse": [ + "discover-result-response.json" + ], + "ElicitationCompleteNotification": [ + "elicitation-complete.json" + ], + "ElicitRequest": [ + "elicitation-request.json" + ], + "ElicitRequestFormParams": [ + "elicit-multiple-fields.json", + "elicit-single-field.json" + ], + "ElicitRequestURLParams": [ + "elicit-sensitive-data.json" + ], + "ElicitResult": [ + "accept-url-mode-no-content.json", + "input-multiple-fields.json", + "input-single-field.json" + ], + "EmbeddedResource": [ + "embedded-file-resource-with-annotations.json" + ], + "GetPromptRequest": [ + "get-prompt-request.json" + ], + "GetPromptRequestParams": [ + "get-code-review-prompt.json" + ], + "GetPromptResult": [ + "code-review-prompt.json" + ], + "GetPromptResultResponse": [ + "get-prompt-result-response.json" + ], + "ImageContent": [ + "image-png-content-with-annotations.json" + ], + "InputRequests": [ + "elicitation-and-sampling-input-requests.json" + ], + "InputRequiredResult": [ + "input-required-result-with-elicitation-and-sampling-and-request-state.json", + "input-required-result-with-request-state-only.json" + ], + "InputResponses": [ + "elicitation-and-sampling-input-responses.json" + ], + "InternalError": [ + "unexpected-error.json" + ], + "InvalidParamsError": [ + "invalid-cursor.json", + "invalid-tool-arguments.json", + "unknown-prompt.json", + "unknown-tool.json" + ], + "ListPromptsRequest": [ + "list-prompts-request.json" + ], + "ListPromptsResult": [ + "prompts-list-with-cursor-and-ttl.json" + ], + "ListPromptsResultResponse": [ + "list-prompts-result-response.json" + ], + "ListResourcesRequest": [ + "list-resources-request.json" + ], + "ListResourcesResult": [ + "resources-list-with-cursor-and-ttl.json" + ], + "ListResourcesResultResponse": [ + "list-resources-result-response.json" + ], + "ListResourceTemplatesRequest": [ + "list-resource-templates-request.json" + ], + "ListResourceTemplatesResult": [ + "resource-templates-list-with-cursor-and-ttl.json" + ], + "ListResourceTemplatesResultResponse": [ + "list-resource-templates-result-response.json" + ], + "ListRootsRequest": [ + "list-roots-request.json" + ], + "ListRootsResult": [ + "multiple-root-directories.json", + "single-root-directory.json" + ], + "ListToolsRequest": [ + "list-tools-request.json" + ], + "ListToolsResult": [ + "tools-list-with-cursor-and-ttl.json" + ], + "ListToolsResultResponse": [ + "list-tools-result-response.json" + ], + "LoggingMessageNotification": [ + "log-database-connection-failed.json" + ], + "LoggingMessageNotificationParams": [ + "log-database-connection-failed.json" + ], + "MethodNotFoundError": [ + "prompts-not-supported.json" + ], + "MissingRequiredClientCapabilityError": [ + "missing-elicitation-capability.json" + ], + "ModelPreferences": [ + "with-hints-and-priorities.json" + ], + "NumberSchema": [ + "number-input-schema.json" + ], + "PaginatedRequestParams": [ + "list-with-cursor.json" + ], + "ParseError": [ + "invalid-json.json" + ], + "ProgressNotification": [ + "progress-message.json" + ], + "ProgressNotificationParams": [ + "progress-message.json" + ], + "PromptListChangedNotification": [ + "prompts-list-changed.json" + ], + "ReadResourceRequest": [ + "read-resource-request.json" + ], + "ReadResourceResult": [ + "file-resource-contents.json" + ], + "ReadResourceResultResponse": [ + "read-resource-result-response-with-ttl.json", + "read-resource-result-response.json" + ], + "Resource": [ + "file-resource-with-annotations.json" + ], + "ResourceLink": [ + "file-resource-link.json" + ], + "ResourceListChangedNotification": [ + "resources-list-changed.json" + ], + "ResourceUpdatedNotification": [ + "file-resource-updated-notification.json" + ], + "ResourceUpdatedNotificationParams": [ + "file-resource-updated.json" + ], + "Root": [ + "project-directory.json" + ], + "SamplingMessage": [ + "multiple-content-blocks.json", + "single-content-block.json" + ], + "ServerCapabilities": [ + "completions-minimum-baseline-support.json", + "extensions-tasks.json", + "logging-minimum-baseline-support.json", + "prompts-list-changed-notifications.json", + "prompts-minimum-baseline-support.json", + "resources-all-notifications.json", + "resources-list-changed-notifications-only.json", + "resources-minimum-baseline-support.json", + "resources-subscription-to-individual-resource-updates-only.json", + "tools-list-changed-notifications.json", + "tools-minimum-baseline-support.json" + ], + "StringSchema": [ + "email-input-schema.json" + ], + "SubscriptionsAcknowledgedNotification": [ + "listen-acknowledged.json" + ], + "SubscriptionsListenRequest": [ + "listen-for-list-changes.json" + ], + "TextContent": [ + "text-content.json" + ], + "TextResourceContents": [ + "text-file-contents.json" + ], + "TitledMultiSelectEnumSchema": [ + "titled-color-multi-select-schema.json" + ], + "TitledSingleSelectEnumSchema": [ + "titled-color-select-schema.json" + ], + "Tool": [ + "tool-with-array-output-schema.json", + "tool-with-composition-input-schema.json", + "with-default-2020-12-input-schema.json", + "with-explicit-draft-07-input-schema.json", + "with-no-parameters.json", + "with-output-schema-for-structured-content.json" + ], + "ToolListChangedNotification": [ + "tools-list-changed.json" + ], + "ToolResultContent": [ + "get-weather-tool-result.json" + ], + "ToolUseContent": [ + "get-weather-tool-use.json" + ], + "UnsupportedProtocolVersionError": [ + "unsupported-version.json" + ], + "UntitledMultiSelectEnumSchema": [ + "color-multi-select-schema.json" + ], + "UntitledSingleSelectEnumSchema": [ + "color-select-schema.json" + ] + } +} diff --git a/packages/core/test/corpus/fixtures/rejection/batch-array-body.json b/packages/core/test/corpus/fixtures/rejection/batch-array-body.json new file mode 100644 index 0000000000..bfea09f9a4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/batch-array-body.json @@ -0,0 +1,11 @@ +{ + "description": "JSON-RPC batch arrays were removed in 2025-06-18; an array message is rejected at classification.", + "message": [ + { + "jsonrpc": "2.0", + "id": 6, + "method": "ping" + } + ], + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/error-response-unknown-id.json b/packages/core/test/corpus/fixtures/rejection/error-response-unknown-id.json new file mode 100644 index 0000000000..97c74928ac --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/error-response-unknown-id.json @@ -0,0 +1,12 @@ +{ + "description": "An error response whose id matches no in-flight request is reported out-of-band.", + "message": { + "jsonrpc": "2.0", + "id": 98, + "error": { + "code": -32603, + "message": "boom" + } + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/invalid-spec-params.json b/packages/core/test/corpus/fixtures/rejection/invalid-spec-params.json new file mode 100644 index 0000000000..5bf8c693f3 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/invalid-spec-params.json @@ -0,0 +1,13 @@ +{ + "description": "A spec request with params that fail the method schema is answered with an error response (current dispatch surfaces the parse failure as -32603 Internal error).", + "message": { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": 123 + } + }, + "expect": "error-response", + "errorCode": -32603 +} diff --git a/packages/core/test/corpus/fixtures/rejection/notification-invalid-spec-params.json b/packages/core/test/corpus/fixtures/rejection/notification-invalid-spec-params.json new file mode 100644 index 0000000000..7ea984842e --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/notification-invalid-spec-params.json @@ -0,0 +1,11 @@ +{ + "description": "A spec notification whose params fail the method schema is dropped; the failure is reported out-of-band and no response is sent.", + "message": { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { + "requestId": true + } + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/notification-unknown-method.json b/packages/core/test/corpus/fixtures/rejection/notification-unknown-method.json new file mode 100644 index 0000000000..2409ad03c8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/notification-unknown-method.json @@ -0,0 +1,8 @@ +{ + "description": "A notification with no registered handler is silently ignored (no response, no out-of-band error).", + "message": { + "jsonrpc": "2.0", + "method": "notifications/definitely-unknown" + }, + "expect": "ignored" +} diff --git a/packages/core/test/corpus/fixtures/rejection/null-request-id.json b/packages/core/test/corpus/fixtures/rejection/null-request-id.json new file mode 100644 index 0000000000..5517f83f3b --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/null-request-id.json @@ -0,0 +1,9 @@ +{ + "description": "A request id of null is invalid (ids are strings or integers); the message is rejected at classification.", + "message": { + "jsonrpc": "2.0", + "id": null, + "method": "ping" + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/request-extra-top-level-key.json b/packages/core/test/corpus/fixtures/rejection/request-extra-top-level-key.json new file mode 100644 index 0000000000..ef0178a1c3 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/request-extra-top-level-key.json @@ -0,0 +1,11 @@ +{ + "description": "A request envelope with an unknown top-level sibling is not a valid JSON-RPC message; dispatch reports it out-of-band and sends no response.", + "message": { + "jsonrpc": "2.0", + "id": 4, + "method": "ping", + "params": {}, + "extraTop": true + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/result-not-an-object.json b/packages/core/test/corpus/fixtures/rejection/result-not-an-object.json new file mode 100644 index 0000000000..6d8018b445 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/result-not-an-object.json @@ -0,0 +1,9 @@ +{ + "description": "A response whose result member is not an object fails envelope classification.", + "message": { + "jsonrpc": "2.0", + "id": 7, + "result": "nope" + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/result-response-unknown-id.json b/packages/core/test/corpus/fixtures/rejection/result-response-unknown-id.json new file mode 100644 index 0000000000..1538b29058 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/result-response-unknown-id.json @@ -0,0 +1,9 @@ +{ + "description": "A result response whose id matches no in-flight request is reported out-of-band.", + "message": { + "jsonrpc": "2.0", + "id": 99, + "result": {} + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/unknown-request-method.json b/packages/core/test/corpus/fixtures/rejection/unknown-request-method.json new file mode 100644 index 0000000000..bd5727183f --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/unknown-request-method.json @@ -0,0 +1,11 @@ +{ + "description": "A request whose method is unknown to the receiver is answered with -32601 Method not found.", + "message": { + "jsonrpc": "2.0", + "id": 1, + "method": "vendor/definitely-unknown", + "params": {} + }, + "expect": "error-response", + "errorCode": -32601 +} diff --git a/packages/core/test/corpus/fixtures/rejection/unregistered-spec-method.json b/packages/core/test/corpus/fixtures/rejection/unregistered-spec-method.json new file mode 100644 index 0000000000..f7b8d91062 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/unregistered-spec-method.json @@ -0,0 +1,13 @@ +{ + "description": "A spec request method with no registered handler is answered with -32601 (handler absence, not schema absence).", + "message": { + "jsonrpc": "2.0", + "id": 2, + "method": "resources/subscribe", + "params": { + "uri": "file:///a.txt" + } + }, + "expect": "error-response", + "errorCode": -32601 +} diff --git a/packages/core/test/corpus/fixtures/rejection/valid-tools-call.json b/packages/core/test/corpus/fixtures/rejection/valid-tools-call.json new file mode 100644 index 0000000000..f25225d874 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/valid-tools-call.json @@ -0,0 +1,15 @@ +{ + "description": "Accept-side dispatch sanity: a valid tools/call request reaches the registered handler and produces a result response.", + "message": { + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { + "name": "echo", + "arguments": { + "text": "hi" + } + } + }, + "expect": "result-response" +} diff --git a/packages/core/test/corpus/fixtures/rejection/wrong-jsonrpc-version.json b/packages/core/test/corpus/fixtures/rejection/wrong-jsonrpc-version.json new file mode 100644 index 0000000000..04d27bf6e4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/wrong-jsonrpc-version.json @@ -0,0 +1,9 @@ +{ + "description": "A message with a jsonrpc member other than '2.0' is not a valid JSON-RPC message; dispatch reports it out-of-band and sends no response.", + "message": { + "jsonrpc": "1.0", + "id": 5, + "method": "ping" + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/specCorpus.test.ts b/packages/core/test/corpus/specCorpus.test.ts new file mode 100644 index 0000000000..d044709485 --- /dev/null +++ b/packages/core/test/corpus/specCorpus.test.ts @@ -0,0 +1,188 @@ +/** + * Spec example corpus — accept-side fixtures parsed through the SDK's wire schemas. + * + * Two corpora, one harness: + * + * - `fixtures/2026-07-28/` is VENDORED from the spec repository's draft + * example set (`schema/draft/examples/`), regenerated only via + * `pnpm fetch:spec-examples` (provenance in its manifest.json). Every + * example directory is named after a spec type; each file is a canonical + * instance of that type. + * - `fixtures/2025-11-25/` is HAND-BUILT and FROZEN: upstream ships no + * example corpus for the released 2025-11-25 revision, so these fixtures + * pin representative 2025-era wire shapes (including the task wire surface + * that revision defines). Do not edit them casually — they are the + * accept-side net for any future change to how 2025-era traffic parses. + * + * Directory-name → schema mapping is mechanical (`

Schema`), with two + * structural exceptions (JSON-RPC response envelopes and bare error objects) + * and an explicit pending list for draft vocabulary the SDK does not model + * yet. The pending list is stale-checked in both directions: a pending entry + * whose schema appears must be removed, and an unmapped directory that is not + * pending fails loudly — no silent skips. + * + * Rejection-side fixtures are deliberately NOT here: accept-only corpora are + * blind to accept→reject deltas, so rejections are routed through real + * dispatch in specCorpusDispatch.test.ts. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + JSONRPCErrorResponseSchema, + JSONRPCResultResponseSchema +} from '../../src/types/schemas.js'; +import * as schemas from '../../src/types/schemas.js'; + +const FIXTURES_ROOT = join(__dirname, 'fixtures'); + +/** JSON-RPC error-object example directories (bare `{code, message, data?}` shapes). */ +const ERROR_OBJECT_DIRS = new Set([ + 'InternalError', + 'InvalidParamsError', + 'MethodNotFoundError', + 'MissingRequiredClientCapabilityError', + 'ParseError', + 'UnsupportedProtocolVersionError' +]); + +/** + * Draft (2026-07-28) vocabulary the SDK does not model yet, at directory + * granularity. Each entry names the reason; the harness asserts the schema is + * genuinely absent so a stale entry (vocabulary landed but still listed) + * fails loudly. These burn down as the corresponding features land. + */ +const PENDING_2026: Record = { + InputRequests: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', + InputRequiredResult: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', + InputResponses: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', + SubscriptionsAcknowledgedNotification: 'subscriptions/listen vocabulary (SEP-1865) is not modeled yet', + SubscriptionsListenRequest: 'subscriptions/listen vocabulary (SEP-1865) is not modeled yet' +}; + +/** + * Individual draft examples whose vocabulary the SDK does not accept yet + * (file granularity — the directory's schema exists but this instance uses a + * draft-only widening). Stale-checked: each listed file must actually FAIL to + * parse, so the entry is removed the moment the widening lands. + */ +const PENDING_2026_FILES: Record = { + 'CallToolResult/result-with-array-structured-content.json': 'array structuredContent (SEP-2549) is not modeled yet', + 'Tool/tool-with-array-output-schema.json': 'array outputSchema (SEP-2549) is not modeled yet' +}; + +type AnyZod = z.ZodType; + +function schemaFor(dir: string, fixture: unknown): AnyZod | undefined { + if (ERROR_OBJECT_DIRS.has(dir)) { + // The upstream error examples mix bare `{code, message, data?}` objects + // with full JSON-RPC error responses — pick by shape. + const isEnveloped = typeof fixture === 'object' && fixture !== null && 'jsonrpc' in fixture; + return isEnveloped ? (JSONRPCErrorResponseSchema as AnyZod) : (JSONRPCErrorResponseSchema.shape.error as AnyZod); + } + if (dir.endsWith('ResultResponse')) return JSONRPCResultResponseSchema as AnyZod; + if (dir === 'CreateMessageResult') { + // The SDK models this spec type as two schemas (single-content and + // tool-use array content); an example instance may be either. + return z.union([CreateMessageResultSchema, CreateMessageResultWithToolsSchema]) as AnyZod; + } + return (schemas as Record)[`${dir}Schema`] as AnyZod | undefined; +} + +function listTypeDirs(revision: string): string[] { + const root = join(FIXTURES_ROOT, revision); + return readdirSync(root) + .filter(entry => statSync(join(root, entry)).isDirectory()) + .sort(); +} + +function listFixtures(revision: string, dir: string): string[] { + return readdirSync(join(FIXTURES_ROOT, revision, dir)) + .filter(file => file.endsWith('.json')) + .sort(); +} + +function loadFixture(revision: string, dir: string, file: string): unknown { + return JSON.parse(readFileSync(join(FIXTURES_ROOT, revision, dir, file), 'utf8')); +} + +describe.each(['2025-11-25', '2026-07-28'] as const)('spec example corpus %s', revision => { + const typeDirs = listTypeDirs(revision); + const pending = revision === '2026-07-28' ? PENDING_2026 : {}; + + const pendingFiles = revision === '2026-07-28' ? PENDING_2026_FILES : {}; + + test('every example directory is mapped to a schema or explicitly pending', () => { + const unmapped = typeDirs.filter(dir => !(dir in pending) && schemaFor(dir, {}) === undefined); + expect(unmapped, 'unmapped example directories — map them or add a documented pending entry').toEqual([]); + }); + + test('pending entries are not stale (their vocabulary is still unmodeled)', () => { + const stale = Object.keys(pending).filter(dir => schemaFor(dir, {}) !== undefined); + expect(stale, 'pending entries whose schema now exists — wire the fixtures and remove the entry').toEqual([]); + // Pending entries must refer to directories that actually exist. + const missing = Object.keys(pending).filter(dir => !typeDirs.includes(dir)); + expect(missing, 'pending entries without a fixture directory').toEqual([]); + + const missingFiles = Object.keys(pendingFiles).filter(relPath => { + const [dir, file] = relPath.split('/'); + if (dir === undefined || file === undefined) return true; + return !typeDirs.includes(dir) || !listFixtures(revision, dir).includes(file); + }); + expect(missingFiles, 'pending file entries without a fixture file').toEqual([]); + }); + + const mappedDirs = typeDirs.filter(dir => !(dir in pending)); + describe.each(mappedDirs)('%s', dir => { + test.each(listFixtures(revision, dir))('%s parses', file => { + const fixture = loadFixture(revision, dir, file); + const schema = schemaFor(dir, fixture); + expect(schema).toBeDefined(); + const parsed = schema!.safeParse(fixture); + const pendingReason = pendingFiles[`${dir}/${file}`]; + if (pendingReason !== undefined) { + // Stale-check: a pending file that parses means the widening + // landed — remove the entry so the example becomes a real pin. + expect(parsed.success, `pending entry is stale ('${dir}/${file}' now parses): ${pendingReason}`).toBe(false); + return; + } + expect(parsed.success, parsed.success ? undefined : `'${dir}/${file}' failed to parse:\n${parsed.error}`).toBe(true); + }); + }); +}); + +describe('corpus inventory pins', () => { + test('the vendored 2026-07-28 corpus matches its manifest (provenance + drift pin)', () => { + const manifest = JSON.parse(readFileSync(join(FIXTURES_ROOT, '2026-07-28', 'manifest.json'), 'utf8')) as { + revision: string; + source: { commit: string }; + directoryCount: number; + fileCount: number; + directories: Record; + }; + expect(manifest.revision).toBe('2026-07-28'); + + const dirs = listTypeDirs('2026-07-28'); + expect(dirs).toEqual(Object.keys(manifest.directories).sort()); + const fileCount = dirs.reduce((sum, dir) => sum + listFixtures('2026-07-28', dir).length, 0); + expect(fileCount).toBe(manifest.fileCount); + + // The corpus size at the pinned spec commit. A change here means the + // vendored corpus was regenerated — review the delta deliberately. + expect(manifest.directoryCount).toBe(86); + expect(manifest.fileCount).toBe(127); + }); + + test('the frozen 2025-11-25 corpus keeps its inventory', () => { + const dirs = listTypeDirs('2025-11-25'); + const fileCount = dirs.reduce((sum, dir) => sum + listFixtures('2025-11-25', dir).length, 0); + // Hand-built and frozen: growing it is welcome (raise the pin in the + // same change); silent shrinkage is not. + expect(fileCount).toBe(47); + }); +}); diff --git a/packages/core/test/corpus/specCorpusDispatch.test.ts b/packages/core/test/corpus/specCorpusDispatch.test.ts new file mode 100644 index 0000000000..88859aa71a --- /dev/null +++ b/packages/core/test/corpus/specCorpusDispatch.test.ts @@ -0,0 +1,121 @@ +/** + * Rejection-side corpus, routed through real dispatch. + * + * Accept-only corpora (specCorpus.test.ts) are blind to accept→reject deltas: + * a schema split or strictness change that turns previously-accepted traffic + * into rejections (or vice versa) never fails a parse-success fixture. These + * fixtures therefore drive raw JSON-RPC messages through a connected + * Protocol — the transport boundary, classification, handler lookup, and + * per-method parse exactly as production dispatch runs them — and pin the + * observable outcome of each: + * + * - `error-response`: an error response with the pinned code is sent back + * - `onerror`: no response; the failure surfaces via onerror + * - `ignored`: no response and no onerror (silent drop) + * - `result-response`: a result response is sent (accept-side sanity) + * + * The fixtures record TODAY's dispatch behavior. When a deliberate change + * moves the accept/reject line, the affected fixture turns red and must be + * updated in the same change (with its changeset / migration entry). + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { Protocol } from '../../src/shared/protocol.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import type { JSONRPCMessage } from '../../src/types/index.js'; + +const REJECTION_DIR = join(__dirname, 'fixtures', 'rejection'); + +interface DispatchFixture { + description: string; + message: unknown; + expect: 'error-response' | 'onerror' | 'ignored' | 'result-response'; + errorCode?: number; +} + +class ReceiverProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +interface Outcome { + responses: JSONRPCMessage[]; + errors: Error[]; +} + +/** Connect a receiver, inject the raw message from the peer side, observe. */ +async function dispatch(message: unknown): Promise { + const [peerTx, receiverTx] = InMemoryTransport.createLinkedPair(); + + const receiver = new ReceiverProtocol(); + const errors: Error[] = []; + receiver.onerror = error => void errors.push(error); + // One registered spec handler so the accept-side fixture has a target. + receiver.setRequestHandler('tools/call', async request => ({ + content: [{ type: 'text', text: String(request.params?.name) }] + })); + await receiver.connect(receiverTx); + + const responses: JSONRPCMessage[] = []; + peerTx.onmessage = received => void responses.push(received); + await peerTx.start(); + + // The InMemoryTransport is typed for valid messages; the cast is the + // point — raw bytes can always carry these shapes to dispatch. + await peerTx.send(message as JSONRPCMessage); + + // Dispatch is asynchronous (handlers run in promise chains); settle. + await new Promise(resolve => setTimeout(resolve, 25)); + + await receiver.close(); + return { responses, errors }; +} + +const fixtureFiles = readdirSync(REJECTION_DIR) + .filter(file => file.endsWith('.json')) + .sort(); + +describe('dispatch-routed corpus (rejection side + accept sanity)', () => { + test('the corpus is present', () => { + expect(fixtureFiles.length).toBeGreaterThanOrEqual(13); + }); + + test.each(fixtureFiles)('%s', async file => { + const fixture = JSON.parse(readFileSync(join(REJECTION_DIR, file), 'utf8')) as DispatchFixture; + const outcome = await dispatch(fixture.message); + + switch (fixture.expect) { + case 'error-response': { + expect(outcome.responses, fixture.description).toHaveLength(1); + const response = outcome.responses[0] as { error?: { code: number } }; + expect(response.error, `expected an error response: ${fixture.description}`).toBeDefined(); + expect(response.error?.code, fixture.description).toBe(fixture.errorCode); + break; + } + case 'result-response': { + expect(outcome.responses, fixture.description).toHaveLength(1); + const response = outcome.responses[0] as { result?: unknown }; + expect(response.result, `expected a result response: ${fixture.description}`).toBeDefined(); + break; + } + case 'onerror': { + expect(outcome.responses, `expected no response: ${fixture.description}`).toHaveLength(0); + expect(outcome.errors.length, `expected an out-of-band error: ${fixture.description}`).toBeGreaterThan(0); + break; + } + case 'ignored': { + expect(outcome.responses, `expected no response: ${fixture.description}`).toHaveLength(0); + expect(outcome.errors, `expected no out-of-band error: ${fixture.description}`).toHaveLength(0); + break; + } + } + }); +}); diff --git a/packages/core/test/types/crossBundleErrorRecognition.test.ts b/packages/core/test/types/crossBundleErrorRecognition.test.ts new file mode 100644 index 0000000000..35f69acbae --- /dev/null +++ b/packages/core/test/types/crossBundleErrorRecognition.test.ts @@ -0,0 +1,131 @@ +/** + * Cross-bundle typed-error recognition guard. + * + * The core package is bundled separately into the client and server dists, so + * a typed error class constructed inside one bundle is NOT `instanceof` the + * "same" class imported from another bundle. The recognition contract is + * therefore: typed protocol errors are materialized from the wire shape — + * numeric `code` plus structurally parsed `error.data` — and consumers (and + * the SDK itself) must never rely on `instanceof` across the package boundary. + * + * These tests pin that contract from both directions: + * - recognition succeeds for plain wire values and for foreign-prototype + * instances (simulating an error object created by another bundled copy of + * core), and + * - recognition is purely structural — malformed `data` falls back to the + * generic class rather than guessing or throwing. + */ +import { describe, expect, test } from 'vitest'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import type { JSONRPCRequest } from '../../src/types/index.js'; +import { ProtocolError, ProtocolErrorCode, UnsupportedProtocolVersionError, UrlElicitationRequiredError } from '../../src/types/index.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +/** + * A structural twin of `UnsupportedProtocolVersionError` with its own + * prototype chain — what an error created by a second bundled copy of core + * looks like to this copy: same name, same fields, different identity. + */ +class ForeignUnsupportedProtocolVersionError extends Error { + readonly code = -32_004; + readonly data = { supported: ['2025-11-25'], requested: '2099-01-01' }; + constructor() { + super('Unsupported protocol version: 2099-01-01'); + this.name = 'UnsupportedProtocolVersionError'; + } +} + +describe('cross-bundle typed-error recognition (data parse, never instanceof)', () => { + test('a -32004 error received over the wire materializes the typed class from code + data', async () => { + // Full dispatch round trip: the peer answers with a plain JSON error + // body — exactly what crosses a transport (and a bundle) boundary. + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + error: { + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: ['2025-11-25', '2025-06-18'], requested: '2099-01-01' } + } + }); + }; + await serverTx.start(); + + const protocol = new TestProtocol(); + await protocol.connect(clientTx); + + const rejection = await protocol.request({ method: 'ping' }).catch((error: unknown) => error); + + // The receiving side gets the typed class, materialized purely from + // the wire shape (numeric code + structurally valid data). + expect(rejection).toBeInstanceOf(UnsupportedProtocolVersionError); + const typed = rejection as UnsupportedProtocolVersionError; + expect(typed.code).toBe(ProtocolErrorCode.UnsupportedProtocolVersion); + expect(typed.supported).toEqual(['2025-11-25', '2025-06-18']); + expect(typed.requested).toBe('2099-01-01'); + + await protocol.close(); + }); + + test('recognition works for a foreign-prototype instance via its code/data, not its identity', () => { + const foreign = new ForeignUnsupportedProtocolVersionError(); + + // The foreign instance is NOT instanceof this bundle's classes — the + // exact situation `instanceof` checks silently get wrong. + expect(foreign instanceof UnsupportedProtocolVersionError).toBe(false); + expect(foreign instanceof ProtocolError).toBe(false); + + // Recognition through the wire shape still succeeds. + const recognized = ProtocolError.fromError(foreign.code, foreign.message, foreign.data); + expect(recognized).toBeInstanceOf(UnsupportedProtocolVersionError); + expect((recognized as UnsupportedProtocolVersionError).supported).toEqual(['2025-11-25']); + expect((recognized as UnsupportedProtocolVersionError).requested).toBe('2099-01-01'); + }); + + test('recognition survives JSON serialization (no prototype information required)', () => { + // Serialize a locally constructed typed error down to its wire shape + // and re-recognize it — the round trip a bundled boundary forces. + const original = new UrlElicitationRequiredError([ + { mode: 'url', message: 'visit', url: 'https://example.com/elicit', elicitationId: 'e1' } + ]); + const wireShape = JSON.parse(JSON.stringify({ code: original.code, message: original.message, data: original.data })) as { + code: number; + message: string; + data: unknown; + }; + + const recognized = ProtocolError.fromError(wireShape.code, wireShape.message, wireShape.data); + expect(recognized).toBeInstanceOf(UrlElicitationRequiredError); + expect((recognized as UrlElicitationRequiredError).elicitations).toHaveLength(1); + expect((recognized as UrlElicitationRequiredError).elicitations[0]?.url).toBe('https://example.com/elicit'); + }); + + test('structurally invalid data falls back to the generic class — no guess, no throw', () => { + // -32004 with data that does not parse as UnsupportedProtocolVersionErrorData. + for (const data of [undefined, null, 'nope', { supported: 'not-an-array', requested: '2099-01-01' }, { wrong: 'shape' }]) { + const recognized = ProtocolError.fromError(-32_004, 'unsupported', data); + expect(recognized).toBeInstanceOf(ProtocolError); + expect(recognized).not.toBeInstanceOf(UnsupportedProtocolVersionError); + expect(recognized.code).toBe(-32_004); + } + + // -32042 with data missing the elicitations array. + const urlFallback = ProtocolError.fromError(-32_042, 'elicitation required', { other: true }); + expect(urlFallback).toBeInstanceOf(ProtocolError); + expect(urlFallback).not.toBeInstanceOf(UrlElicitationRequiredError); + }); +}); diff --git a/scripts/fetch-spec-examples.ts b/scripts/fetch-spec-examples.ts new file mode 100644 index 0000000000..2c53f77df6 --- /dev/null +++ b/scripts/fetch-spec-examples.ts @@ -0,0 +1,141 @@ +/** + * Vendors the draft-revision (2026-07-28) example corpus from the spec + * repository into `packages/core/test/corpus/fixtures/2026-07-28/`. + * + * The spec repository ships canonical example instances for the draft schema + * (`schema/draft/examples//*.json`). The corpus harness + * (`packages/core/test/corpus/specCorpus.test.ts`) parses every vendored + * example through the SDK's wire schemas, so accept-side drift between the + * SDK and the specification turns CI red. + * + * Files are vendored verbatim, plus a `manifest.json` recording provenance + * (source commit) and the directory/file inventory so corpus drift is loud. + * + * Usage: + * pnpm fetch:spec-examples --spec-dir + * pnpm fetch:spec-examples [sha] # fetch from GitHub (default: latest main) + * + * With `--spec-dir`, examples are read from a local checkout of + * modelcontextprotocol/modelcontextprotocol (provenance is the checkout's + * HEAD commit). Without it, sources are fetched from GitHub at the given + * commit, mirroring scripts/fetch-spec-types.ts. + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const PROJECT_ROOT = join(dirname(__filename), '..'); + +const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol'; +/** The upcoming protocol revision; its examples live in the spec repo's draft directory. */ +const DRAFT_REVISION = '2026-07-28'; +const EXAMPLES_PATH = 'schema/draft/examples'; +const OUTPUT_DIR = join(PROJECT_ROOT, 'packages', 'core', 'test', 'corpus', 'fixtures', DRAFT_REVISION); + +interface ExampleFile { + /** `/.json` relative to the examples root. */ + relPath: string; + content: string; +} + +async function fetchLatestSHA(): Promise { + const url = `https://api.github.com/repos/${SPEC_REPO}/commits?path=${EXAMPLES_PATH}&per_page=1`; + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch commit info: ${response.status} ${response.statusText}`); + const commits = (await response.json()) as Array<{ sha: string }>; + if (!commits?.length) throw new Error('No commits found for the examples path'); + return commits[0].sha; +} + +async function listExamplesFromGitHub(sha: string): Promise { + const url = `https://api.github.com/repos/${SPEC_REPO}/git/trees/${sha}?recursive=1`; + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch repo tree: ${response.status} ${response.statusText}`); + const tree = (await response.json()) as { truncated?: boolean; tree: Array<{ path: string; type: string }> }; + if (tree.truncated) throw new Error('GitHub tree listing truncated; cannot enumerate examples reliably'); + return tree.tree + .filter(entry => entry.type === 'blob' && entry.path.startsWith(`${EXAMPLES_PATH}/`) && entry.path.endsWith('.json')) + .map(entry => entry.path.slice(EXAMPLES_PATH.length + 1)); +} + +async function fetchExamplesFromGitHub(sha: string): Promise { + const relPaths = await listExamplesFromGitHub(sha); + const files: ExampleFile[] = []; + for (const relPath of relPaths) { + const url = `https://raw.githubusercontent.com/${SPEC_REPO}/${sha}/${EXAMPLES_PATH}/${relPath}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch ${relPath}: ${response.status} ${response.statusText}`); + files.push({ relPath, content: await response.text() }); + } + return files; +} + +function readExamplesFromDir(specDir: string): { files: ExampleFile[]; sha: string } { + const root = join(specDir, ...EXAMPLES_PATH.split('/')); + const files: ExampleFile[] = []; + for (const typeDir of readdirSync(root).sort()) { + const dirPath = join(root, typeDir); + if (!statSync(dirPath).isDirectory()) continue; + for (const file of readdirSync(dirPath).sort()) { + if (!file.endsWith('.json')) continue; + files.push({ relPath: `${typeDir}/${file}`, content: readFileSync(join(dirPath, file), 'utf8') }); + } + } + const sha = execFileSync('git', ['-C', specDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + return { files, sha }; +} + +function writeCorpus(files: ExampleFile[], sha: string): void { + if (files.length === 0) throw new Error('No example files found — refusing to write an empty corpus'); + + rmSync(OUTPUT_DIR, { recursive: true, force: true }); + mkdirSync(OUTPUT_DIR, { recursive: true }); + + const dirs: Record = {}; + for (const file of files.sort((a, b) => a.relPath.localeCompare(b.relPath))) { + const [typeDir, fileName] = file.relPath.split('/'); + if (!typeDir || !fileName) throw new Error(`Unexpected example path: ${file.relPath}`); + mkdirSync(join(OUTPUT_DIR, typeDir), { recursive: true }); + // Validate now so a malformed upstream example fails the vendoring, not the harness. + JSON.parse(file.content); + writeFileSync(join(OUTPUT_DIR, typeDir, fileName), file.content); + (dirs[typeDir] ??= []).push(fileName); + } + + const manifest = { + revision: DRAFT_REVISION, + source: { repo: SPEC_REPO, path: EXAMPLES_PATH, commit: sha }, + regenerate: 'pnpm fetch:spec-examples --spec-dir # or [sha] to fetch from GitHub', + directoryCount: Object.keys(dirs).length, + fileCount: files.length, + directories: dirs + }; + writeFileSync(join(OUTPUT_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 4)}\n`); + + console.log(`Vendored ${files.length} example files across ${Object.keys(dirs).length} directories (source ${sha.slice(0, 8)})`); +} + +async function main(): Promise { + const args = process.argv.slice(2); + const specDirIndex = args.indexOf('--spec-dir'); + + if (specDirIndex !== -1) { + const specDir = args[specDirIndex + 1]; + if (!specDir) throw new Error('--spec-dir requires a path argument'); + const { files, sha } = readExamplesFromDir(specDir); + writeCorpus(files, sha); + return; + } + + const sha = args[0] ?? (await fetchLatestSHA()); + const files = await fetchExamplesFromGitHub(sha); + writeCorpus(files, sha); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); From eeeb3ba75305ceda8da140d6414a634f6a8a2b02 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:34:33 +0100 Subject: [PATCH 08/37] feat(core)!: hide wire-only members from the public types; lift them to ctx.mcpReq (#2293) --- .changeset/hide-wire-only-members.md | 7 + docs/migration-SKILL.md | 16 +- docs/migration.md | 34 +- packages/client/etc/client.api.md | 640 +++--------------- packages/client/etc/client.stdio.api.md | 71 +- packages/client/src/client/client.ts | 36 +- .../test/client/clientTypeSurface.test.ts | 30 + packages/core/src/errors/sdkErrors.ts | 6 + packages/core/src/shared/protocol.ts | 182 ++++- packages/core/src/types/constants.ts | 5 + packages/core/src/types/guards.ts | 3 + packages/core/src/types/schemas.ts | 60 +- packages/core/src/types/specTypeSchema.ts | 6 +- packages/core/src/types/types.ts | 131 ++-- .../test/shared/rawResultTypeFirst.test.ts | 151 +++++ .../test/shared/typedMapAlignment.test.ts | 132 ++++ .../core/test/shared/wireOnlyLift.test.ts | 329 +++++++++ .../core/test/spec.types.2025-11-25.test.ts | 50 +- .../core/test/types/errorSurfacePins.test.ts | 1 + .../core/test/types/specTypeSchema.test.ts | 8 +- .../core/test/types/wireOnlyHiding.test.ts | 188 +++++ packages/middleware/node/etc/node.api.md | 71 +- .../server-legacy/etc/server-legacy.api.md | 71 +- .../etc/server-legacy.sse.api.md | 71 +- packages/server/etc/server.api.md | 263 +++---- packages/server/etc/server.stdio.api.md | 71 +- packages/server/src/server/server.ts | 6 +- packages/server/test/server/server.test.ts | 24 + scripts/fetch-spec-examples.ts | 17 +- test/e2e/requirements.ts | 7 + test/e2e/scenarios/raw-result-type.test.ts | 92 +++ 31 files changed, 1896 insertions(+), 883 deletions(-) create mode 100644 .changeset/hide-wire-only-members.md create mode 100644 packages/client/test/client/clientTypeSurface.test.ts create mode 100644 packages/core/test/shared/rawResultTypeFirst.test.ts create mode 100644 packages/core/test/shared/typedMapAlignment.test.ts create mode 100644 packages/core/test/shared/wireOnlyLift.test.ts create mode 100644 packages/core/test/types/wireOnlyHiding.test.ts create mode 100644 test/e2e/scenarios/raw-result-type.test.ts diff --git a/.changeset/hide-wire-only-members.md b/.changeset/hide-wire-only-members.md new file mode 100644 index 0000000000..7d241921f5 --- /dev/null +++ b/.changeset/hide-wire-only-members.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': major +'@modelcontextprotocol/client': major +'@modelcontextprotocol/server': major +--- + +Hide wire-only protocol members from the public surface, at the type level and at runtime. `resultType` (the 2026-07-28 result discrimination field) is no longer declared on any public result type — the wire schemas keep parsing it, and the client funnel now consumes it raw-first: `'complete'` results are stripped to the public shape and any other kind (e.g. `input_required`) rejects with the new `SdkErrorCode.UnsupportedResultType` instead of masking into an empty success. The reserved `_meta` envelope keys are lifted out of inbound requests and notifications before handlers run, and the multi-round-trip retry fields (`inputResponses`, `requestState`) out of inbound requests only (the spec reserves those names on client-initiated requests; notification params keep them), so handler params keep the 2025-era shape; for requests the lifted material surfaces at `ctx.mcpReq.envelope`, `ctx.mcpReq.inputResponses`, and `ctx.mcpReq.requestState` (notifications have no ctx — their lifted envelope keys are not surfaced). High-level client/server methods now return the named public result types (`Promise` etc.). Task wire vocabulary stays importable but is `@deprecated` and excluded from the typed method maps (`RequestMethod`/`RequestTypeMap`/`ResultTypeMap`/`NotificationTypeMap`), and `callTool` is typed as plain `CallToolResult`. See docs/migration.md "Wire-only protocol members hidden from the public types". diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index aef327622c..312229a8c5 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -501,7 +501,21 @@ The 2025-11 task side-channel through `Protocol` is removed (was always `@experi `TaskStore` / `InMemoryTaskStore` / `CreateTaskOptions` / `isTerminal` (storage layer) are also removed; they will return with the SEP-2663 server-directed plugin. -NOT removed (wire surface, kept for 2025-11-25 interop): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), task members of the request/result/notification unions, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`. +NOT removed (wire surface, kept for 2025-11-25 interop, now `@deprecated`): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), task members of the request/result/notification union types, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`. + +Task methods are excluded from the typed method maps: `RequestMethod`/`RequestTypeMap`/`ResultTypeMap` have no `tasks/*` entries and `NotificationMethod`/`NotificationTypeMap` have no `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task methods at compile time. Mechanical fix where task interop is genuinely required: pass an explicit schema (`request({ method: 'tasks/get', params }, GetTaskResultSchema)`-style custom-method form). `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. + +## 12b. Wire-only members hidden from public types + +`resultType` (2026-07-28 result discrimination) is no longer declared on any public result type; the SDK parses and consumes it internally. The reserved `_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`) and retry fields (`inputResponses`, `requestState`) appear in no public params/result type. `RequestMetaEnvelope` and the `*_META_KEY` constants remain exported. + +| Pattern in v2-alpha code | Mechanical fix | +| ------------------------------------- | --------------------------------------------------------------------------------- | +| `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered | +| `Result['resultType']` type reference | remove; the member is no longer declared | +| return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | + +Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. A response carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`). Collision note for 2025-era peers: 2025-11-25 reserves the `io.modelcontextprotocol/` `_meta` prefix but NOT the bare names `inputResponses`/`requestState`, so a 2025 peer's custom-method request using those names as ordinary params has them lifted out of `request.params` (recoverable via ctx; everything else passes through untouched). ## 13. Behavioral Changes diff --git a/docs/migration.md b/docs/migration.md index 576f6c5ce4..afef43025b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -902,10 +902,42 @@ The 2025-11 experimental tasks side-channel woven through `Protocol` has been re **Also removed:** the storage layer (`TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`). It will return as part of the SEP-2663 server-directed plugin in a follow-up. -**Wire types remain.** The task wire surface defined by the 2025-11-25 protocol revision is still exported, for interoperability with peers on that revision: the task Zod schemas and their inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `GetTaskPayload*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification unions, the `tasks` capability key, the `isTaskAugmentedRequestParams` guard, and `RELATED_TASK_META_KEY`. Only the behavior is gone: servers built on this SDK do not advertise the `tasks` capability, and inbound `tasks/*` requests receive a standard `-32601` (method not found) error. +**Wire types remain, as deprecated vocabulary.** The task wire surface defined by the 2025-11-25 protocol revision is still exported, for interoperability with peers on that revision: the task Zod schemas and their inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `GetTaskPayload*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification union types, the `tasks` capability key, the `isTaskAugmentedRequestParams` guard, and `RELATED_TASK_META_KEY`. These exports are now marked `@deprecated` (importable wire vocabulary only; removable at the major version that drops 2025-era support), and the typed method surface no longer offers task methods: `RequestMethod`/`RequestTypeMap`/`ResultTypeMap`/`NotificationTypeMap` exclude `tasks/*` and `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, and `setNotificationHandler()` do not accept them (the explicit-schema overloads still work for custom interop). The method-keyed result types are narrowed to match: `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`), and likewise `sampling/createMessage` and `elicitation/create` lose their task-result union members — the runtime result validation uses the same plain schemas, so a task-shaped response body to one of these methods fails as a local `INVALID_RESULT` error where the result schema rejects it rather than parsing into a mis-typed success. Only the behavior is gone: servers built on this SDK do not advertise the `tasks` capability, and inbound `tasks/*` requests receive a standard `-32601` (method not found) error. There is no migration path for the removed surface; it was always `@experimental`. Task support is planned to return as an opt-in extension plugin per SEP-2663. +### Wire-only protocol members hidden from the public types + +The protocol revision 2026-07-28 introduces wire-level bookkeeping that the SDK handles internally and that never needs to reach application code: the `resultType` result discrimination field, the reserved per-request `_meta` envelope keys (`io.modelcontextprotocol/protocolVersion`, `io.modelcontextprotocol/clientInfo`, `io.modelcontextprotocol/clientCapabilities`, `io.modelcontextprotocol/logLevel`), and the multi-round-trip retry fields (`inputResponses`, `requestState`). The public TypeScript surface no longer declares these members: + +- **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, `GetPromptResult`, …, and the `result` member of `JSONRPCResultResponse`). The wire schemas keep parsing it, and the protocol layer consumes it before results reach your code. If you previously read `result.resultType` (it was always `undefined` from conforming 2025-era peers), drop the read — the SDK now owns that field. +- **High-level methods return the named public types.** `client.callTool()` returns `Promise`, `client.listTools()` returns `Promise`, and so on (previously these returned structurally inferred schema types that exposed `resultType?`). Handler return positions are unaffected: results you build keep type-checking, and unknown members still pass through the loose index signature. +- **The reserved envelope keys and retry fields never appear in a public params/result type.** The `RequestMetaEnvelope` type and the four `*_META_KEY` constants stay exported — they document the wire names and type the context surfacing channel (see below). + +The protocol layer enforces the same boundary at runtime: + +- **Envelope lift.** On inbound requests and notifications, the reserved `io.modelcontextprotocol/*` envelope keys are lifted out of `params._meta` before handlers run, so handler params are byte-equal to the 2025-era shape under 2026-era traffic. For requests the envelope is readable at `ctx.mcpReq.envelope` (typed `Partial` — only the keys the request actually carried are present); for notifications there is no per-message context, so lifted envelope keys are dropped, not surfaced. On requests only, the multi-round-trip retry fields are likewise lifted out of top-level params and surfaced verbatim at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. +- **What this means for 2025-era peers.** The `_meta` side of the lift is invisible to conforming 2025-era traffic: the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too, so a conforming 2025 peer never puts application data under those keys. The retry-field lift is the one collision to know about: 2025-11-25 does not reserve the bare names `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that happens to use them as ordinary top-level params will have them lifted out of the handler's view (still readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`, just no longer in `request.params`). Spec-method requests are unaffected (no 2025 spec method defines params with those names), as are all notifications. +- **Raw-first result discrimination.** The client funnel inspects a response's raw `resultType` before schema validation: `'complete'` is consumed (stripped) and the result parses as the public shape; any other kind (e.g. `input_required`) rejects with a typed local error — `SdkError` with the new code `SdkErrorCode.UnsupportedResultType` and the kind in `error.data.resultType` — instead of being masked into a hollow success by tolerant result schemas. Full multi-round-trip support will replace that error arm. + +**Before (v2 alpha):** + +```typescript +const result = await client.callTool({ name: 'echo', arguments: {} }); +// result.resultType was declared as `string | undefined` and always undefined +if (result.resultType === undefined || result.resultType === 'complete') { + console.log(result.content); +} +``` + +**After:** + +```typescript +const result = await client.callTool({ name: 'echo', arguments: {} }); +// resultType is wire-level bookkeeping the SDK consumes; just use the result +console.log(result.content); +``` + ## Enhancements ### Automatic JSON Schema validator selection by runtime diff --git a/packages/client/etc/client.api.md b/packages/client/etc/client.api.md index af41d5f6e4..7e9108dfd2 100644 --- a/packages/client/etc/client.api.md +++ b/packages/client/etc/client.api.md @@ -104,6 +104,9 @@ export type BaseContext = { id: RequestId; method: string; _meta?: RequestMeta; + envelope?: Partial; + inputResponses?: Record; + requestState?: string; signal: AbortSignal; send: { (request: { @@ -206,7 +209,7 @@ const CallToolRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type CallToolResult = Infer; +export type CallToolResult = StripWireOnly>; // @public const CallToolResultSchema: z.ZodObject<{ @@ -308,10 +311,10 @@ const CallToolResultSchema: z.ZodObject<{ isError: z.ZodOptional; }, z.core.$loose>; -// @public (undocumented) +// @public @deprecated (undocumented) export type CancelTaskRequest = Infer; -// @public +// @public @deprecated const CancelTaskRequestSchema: z.ZodObject<{ method: z.ZodLiteral<"tasks/cancel">; params: z.ZodObject<{ @@ -325,10 +328,10 @@ const CancelTaskRequestSchema: z.ZodObject<{ }, z.core.$strip>; }, z.core.$strip>; -// @public (undocumented) -export type CancelTaskResult = Infer; +// @public @deprecated (undocumented) +export type CancelTaskResult = StripWireOnly>; -// @public +// @public @deprecated const CancelTaskResultSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -410,407 +413,26 @@ export class Client extends Protocol { protected assertRequestHandlerCapability(method: string): void; // (undocumented) protected buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext; - callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise<{ - [x: string]: unknown; - content: ({ - type: "text"; - text: string; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: Record | undefined; - } | { - type: "image"; - data: string; - mimeType: string; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: Record | undefined; - } | { - type: "audio"; - data: string; - mimeType: string; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: Record | undefined; - } | { - uri: string; - name: string; - type: "resource_link"; - description?: string | undefined; - mimeType?: string | undefined; - size?: number | undefined; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: { - [x: string]: unknown; - } | undefined; - icons?: { - src: string; - mimeType?: string | undefined; - sizes?: string[] | undefined; - theme?: "light" | "dark" | undefined; - }[] | undefined; - title?: string | undefined; - } | { - type: "resource"; - resource: { - uri: string; - text: string; - mimeType?: string | undefined; - _meta?: Record | undefined; - } | { - uri: string; - blob: string; - mimeType?: string | undefined; - _meta?: Record | undefined; - }; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: Record | undefined; - })[]; - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - structuredContent?: Record | undefined; - isError?: boolean | undefined; - }>; - complete(params: CompleteRequest['params'], options?: RequestOptions): Promise<{ - [x: string]: unknown; - completion: { - [x: string]: unknown; - values: string[]; - total?: number | undefined; - hasMore?: boolean | undefined; - }; - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - }>; + callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise; + complete(params: CompleteRequest['params'], options?: RequestOptions): Promise; connect(transport: Transport, options?: RequestOptions): Promise; getInstructions(): string | undefined; getNegotiatedProtocolVersion(): string | undefined; - getPrompt(params: GetPromptRequest['params'], options?: RequestOptions): Promise<{ - [x: string]: unknown; - messages: { - role: "user" | "assistant"; - content: { - type: "text"; - text: string; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: Record | undefined; - } | { - type: "image"; - data: string; - mimeType: string; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: Record | undefined; - } | { - type: "audio"; - data: string; - mimeType: string; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: Record | undefined; - } | { - uri: string; - name: string; - type: "resource_link"; - description?: string | undefined; - mimeType?: string | undefined; - size?: number | undefined; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: { - [x: string]: unknown; - } | undefined; - icons?: { - src: string; - mimeType?: string | undefined; - sizes?: string[] | undefined; - theme?: "light" | "dark" | undefined; - }[] | undefined; - title?: string | undefined; - } | { - type: "resource"; - resource: { - uri: string; - text: string; - mimeType?: string | undefined; - _meta?: Record | undefined; - } | { - uri: string; - blob: string; - mimeType?: string | undefined; - _meta?: Record | undefined; - }; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: Record | undefined; - }; - }[]; - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - description?: string | undefined; - }>; + getPrompt(params: GetPromptRequest['params'], options?: RequestOptions): Promise; getServerCapabilities(): ServerCapabilities | undefined; getServerVersion(): Implementation | undefined; - listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions): Promise<{ - [x: string]: unknown; - prompts: { - name: string; - description?: string | undefined; - arguments?: { - name: string; - description?: string | undefined; - required?: boolean | undefined; - }[] | undefined; - _meta?: { - [x: string]: unknown; - } | undefined; - icons?: { - src: string; - mimeType?: string | undefined; - sizes?: string[] | undefined; - theme?: "light" | "dark" | undefined; - }[] | undefined; - title?: string | undefined; - }[]; - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - nextCursor?: string | undefined; - }>; - listResources(params?: ListResourcesRequest['params'], options?: RequestOptions): Promise<{ - [x: string]: unknown; - resources: { - uri: string; - name: string; - description?: string | undefined; - mimeType?: string | undefined; - size?: number | undefined; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: { - [x: string]: unknown; - } | undefined; - icons?: { - src: string; - mimeType?: string | undefined; - sizes?: string[] | undefined; - theme?: "light" | "dark" | undefined; - }[] | undefined; - title?: string | undefined; - }[]; - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - nextCursor?: string | undefined; - }>; - listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions): Promise<{ - [x: string]: unknown; - resourceTemplates: { - uriTemplate: string; - name: string; - description?: string | undefined; - mimeType?: string | undefined; - annotations?: { - audience?: ("user" | "assistant")[] | undefined; - priority?: number | undefined; - lastModified?: string | undefined; - } | undefined; - _meta?: { - [x: string]: unknown; - } | undefined; - icons?: { - src: string; - mimeType?: string | undefined; - sizes?: string[] | undefined; - theme?: "light" | "dark" | undefined; - }[] | undefined; - title?: string | undefined; - }[]; - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - nextCursor?: string | undefined; - }>; - listTools(params?: ListToolsRequest['params'], options?: RequestOptions): Promise<{ - [x: string]: unknown; - tools: { - inputSchema: { - [x: string]: unknown; - type: "object"; - properties?: Record | undefined; - required?: string[] | undefined; - }; - name: string; - description?: string | undefined; - outputSchema?: { - [x: string]: unknown; - type: "object"; - properties?: Record | undefined; - required?: string[] | undefined; - } | undefined; - annotations?: { - title?: string | undefined; - readOnlyHint?: boolean | undefined; - destructiveHint?: boolean | undefined; - idempotentHint?: boolean | undefined; - openWorldHint?: boolean | undefined; - } | undefined; - execution?: { - taskSupport?: "optional" | "required" | "forbidden" | undefined; - } | undefined; - _meta?: Record | undefined; - icons?: { - src: string; - mimeType?: string | undefined; - sizes?: string[] | undefined; - theme?: "light" | "dark" | undefined; - }[] | undefined; - title?: string | undefined; - }[]; - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - nextCursor?: string | undefined; - }>; + listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions): Promise; + listResources(params?: ListResourcesRequest['params'], options?: RequestOptions): Promise; + listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions): Promise; + listTools(params?: ListToolsRequest['params'], options?: RequestOptions): Promise; // (undocumented) - ping(options?: RequestOptions): Promise<{ - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - }>; - readResource(params: ReadResourceRequest['params'], options?: RequestOptions): Promise<{ - [x: string]: unknown; - contents: ({ - uri: string; - text: string; - mimeType?: string | undefined; - _meta?: Record | undefined; - } | { - uri: string; - blob: string; - mimeType?: string | undefined; - _meta?: Record | undefined; - })[]; - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - }>; + ping(options?: RequestOptions): Promise; + readResource(params: ReadResourceRequest['params'], options?: RequestOptions): Promise; registerCapabilities(capabilities: ClientCapabilities): void; sendRootsListChanged(): Promise; - setLoggingLevel(level: LoggingLevel, options?: RequestOptions): Promise<{ - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - }>; - subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions): Promise<{ - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - }>; - unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions): Promise<{ - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - }>; + setLoggingLevel(level: LoggingLevel, options?: RequestOptions): Promise; + subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions): Promise; + unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions): Promise; protected _wrapHandler(method: string, handler: (request: JSONRPCRequest, ctx: ClientContext) => Promise): (request: JSONRPCRequest, ctx: ClientContext) => Promise; } @@ -1234,7 +856,7 @@ const ClientRequestSchema: z.ZodUnion]>; // @public (undocumented) -export type ClientResult = Infer; +export type ClientResult = StripWireOnly>; // @public (undocumented) const ClientResultSchema: z.ZodUnion]>; // @public (undocumented) -export type CompatibilityCallToolResult = Infer; +export type CompatibilityCallToolResult = StripWireOnly>; // @public const CompatibilityCallToolResultSchema: z.ZodUnion<[z.ZodObject<{ @@ -1877,7 +1499,7 @@ const CompleteRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type CompleteResult = Infer; +export type CompleteResult = StripWireOnly>; // @public const CompleteResultSchema: z.ZodObject<{ @@ -2724,7 +2346,7 @@ const CreateMessageRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type CreateMessageResult = Infer; +export type CreateMessageResult = StripWireOnly>; // @public const CreateMessageResultSchema: z.ZodObject<{ @@ -2787,7 +2409,7 @@ const CreateMessageResultSchema: z.ZodObject<{ }, z.core.$loose>; // @public (undocumented) -export type CreateMessageResultWithTools = Infer; +export type CreateMessageResultWithTools = StripWireOnly>; // @public const CreateMessageResultWithToolsSchema: z.ZodObject<{ @@ -3086,10 +2708,10 @@ const CreateMessageResultWithToolsSchema: z.ZodObject<{ }, z.core.$strip>], "type">>]>; }, z.core.$loose>; -// @public (undocumented) -export type CreateTaskResult = Infer; +// @public @deprecated (undocumented) +export type CreateTaskResult = StripWireOnly>; -// @public +// @public @deprecated const CreateTaskResultSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -3195,7 +2817,7 @@ const DiscoverRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type DiscoverResult = Infer; +export type DiscoverResult = StripWireOnly>; // @public const DiscoverResultSchema: z.ZodObject<{ @@ -3594,7 +3216,7 @@ const ElicitRequestURLParamsSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ElicitResult = Infer; +export type ElicitResult = StripWireOnly>; // @public const ElicitResultSchema: z.ZodObject<{ @@ -3673,7 +3295,7 @@ const EmbeddedResourceSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type EmptyResult = Infer; +export type EmptyResult = StripWireOnly>; // @public const EmptyResultSchema: z.ZodObject<{ @@ -3781,7 +3403,7 @@ const GetPromptRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type GetPromptResult = Infer; +export type GetPromptResult = StripWireOnly>; // @public const GetPromptResultSchema: z.ZodObject<{ @@ -3888,10 +3510,10 @@ const GetPromptResultSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$loose>; -// @public (undocumented) +// @public @deprecated (undocumented) export type GetTaskPayloadRequest = Infer; -// @public +// @public @deprecated const GetTaskPayloadRequestSchema: z.ZodObject<{ method: z.ZodLiteral<"tasks/result">; params: z.ZodObject<{ @@ -3905,10 +3527,10 @@ const GetTaskPayloadRequestSchema: z.ZodObject<{ }, z.core.$strip>; }, z.core.$strip>; -// @public (undocumented) -export type GetTaskPayloadResult = Infer; +// @public @deprecated (undocumented) +export type GetTaskPayloadResult = StripWireOnly>; -// @public +// @public @deprecated const GetTaskPayloadResultSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -3919,10 +3541,10 @@ const GetTaskPayloadResultSchema: z.ZodObject<{ resultType: z.ZodOptional; }, z.core.$loose>; -// @public (undocumented) +// @public @deprecated (undocumented) export type GetTaskRequest = Infer; -// @public +// @public @deprecated const GetTaskRequestSchema: z.ZodObject<{ method: z.ZodLiteral<"tasks/get">; params: z.ZodObject<{ @@ -3936,10 +3558,10 @@ const GetTaskRequestSchema: z.ZodObject<{ }, z.core.$strip>; }, z.core.$strip>; -// @public (undocumented) -export type GetTaskResult = Infer; +// @public @deprecated (undocumented) +export type GetTaskResult = StripWireOnly>; -// @public +// @public @deprecated const GetTaskResultSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -4196,7 +3818,7 @@ const InitializeRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type InitializeResult = Infer; +export type InitializeResult = StripWireOnly>; // @public const InitializeResultSchema: z.ZodObject<{ @@ -4316,53 +3938,7 @@ const JSONRPCErrorResponseSchema: z.ZodObject<{ }, z.core.$strict>; // @public (undocumented) -export type JSONRPCMessage = Infer; - -// @public (undocumented) -const JSONRPCMessageSchema: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>, z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>]>; +export type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; // @public (undocumented) export type JSONRPCNotification = Infer; @@ -4400,33 +3976,12 @@ const JSONRPCRequestSchema: z.ZodObject<{ }, z.core.$strict>; // @public (undocumented) -export type JSONRPCResponse = Infer; +export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; // @public (undocumented) -const JSONRPCResponseSchema: z.ZodUnion; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>]>; - -// @public (undocumented) -export type JSONRPCResultResponse = Infer; +export type JSONRPCResultResponse = Omit, 'result'> & { + result: Result; +}; // @public const JSONRPCResultResponseSchema: z.ZodObject<{ @@ -4527,7 +4082,7 @@ const ListPromptsRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ListPromptsResult = Infer; +export type ListPromptsResult = StripWireOnly>; // @public const ListPromptsResultSchema: z.ZodObject<{ @@ -4579,7 +4134,7 @@ const ListResourceTemplatesRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ListResourceTemplatesResult = Infer; +export type ListResourceTemplatesResult = StripWireOnly>; // @public const ListResourceTemplatesResultSchema: z.ZodObject<{ @@ -4636,7 +4191,7 @@ const ListResourcesRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ListResourcesResult = Infer; +export type ListResourcesResult = StripWireOnly>; // @public const ListResourcesResultSchema: z.ZodObject<{ @@ -4693,7 +4248,7 @@ const ListRootsRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ListRootsResult = Infer; +export type ListRootsResult = StripWireOnly>; // @public const ListRootsResultSchema: z.ZodObject<{ @@ -4711,10 +4266,10 @@ const ListRootsResultSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$loose>; -// @public (undocumented) +// @public @deprecated (undocumented) export type ListTasksRequest = Infer; -// @public +// @public @deprecated const ListTasksRequestSchema: z.ZodObject<{ params: z.ZodOptional; }, z.core.$strip>; -// @public (undocumented) -export type ListTasksResult = Infer; +// @public @deprecated (undocumented) +export type ListTasksResult = StripWireOnly>; -// @public +// @public @deprecated const ListTasksResultSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -4776,7 +4331,7 @@ const ListToolsRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ListToolsResult = Infer; +export type ListToolsResult = StripWireOnly>; // @public const ListToolsResultSchema: z.ZodObject<{ @@ -4985,7 +4540,7 @@ const MultiSelectEnumSchemaSchema: z.ZodUnion]>; // @public (undocumented) -export type NotificationMethod = ClientNotification['method'] | ServerNotification['method']; +export type NotificationMethod = Exclude; // @public type NotificationOptions_2 = { @@ -5010,7 +4565,9 @@ const NotificationSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type NotificationTypeMap = MethodToTypeMap; +export type NotificationTypeMap = MethodToTypeMap>; // @public (undocumented) type Notification_2 = Infer; @@ -5388,7 +4945,7 @@ const PaginatedRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type PaginatedResult = Infer; +export type PaginatedResult = StripWireOnly>; // @public (undocumented) const PaginatedResultSchema: z.ZodObject<{ @@ -5841,7 +5398,7 @@ export type ProtocolOptions = { // @public (undocumented) type ProtocolSchemaKey = (typeof SPEC_SCHEMA_KEYS)[number]; -// @public (undocumented) +// @public @deprecated export const RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; // @public @@ -5889,7 +5446,7 @@ const ReadResourceRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ReadResourceResult = Infer; +export type ReadResourceResult = StripWireOnly>; // @public const ReadResourceResultSchema: z.ZodObject<{ @@ -5916,10 +5473,10 @@ const ReadResourceResultSchema: z.ZodObject<{ // @public export type ReconnectionScheduler = (reconnect: () => void, delay: number, attemptCount: number) => (() => void) | void; -// @public (undocumented) +// @public @deprecated (undocumented) export type RelatedTaskMetadata = Infer; -// @public +// @public @deprecated const RelatedTaskMetadataSchema: z.ZodObject<{ taskId: z.ZodString; }, z.core.$strip>; @@ -6041,7 +5598,7 @@ const RequestMetaSchema: z.ZodObject<{ }, z.core.$loose>; // @public (undocumented) -export type RequestMethod = ClientRequest['method'] | ServerRequest['method']; +export type RequestMethod = Exclude; // @public export type RequestOptions = { @@ -6069,7 +5626,9 @@ const RequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type RequestTypeMap = MethodToTypeMap; +export type RequestTypeMap = MethodToTypeMap>; // @public (undocumented) type Request_2 = Infer; @@ -6249,7 +5808,7 @@ const ResourceUpdatedNotificationSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type Result = Infer; +export type Result = StripWireOnly>; // @public (undocumented) const ResultSchema: z.ZodObject<{ @@ -6275,15 +5834,11 @@ export type ResultTypeMap = { 'resources/read': ReadResourceResult; 'resources/subscribe': EmptyResult; 'resources/unsubscribe': EmptyResult; - 'tools/call': CallToolResult | CreateTaskResult; + 'tools/call': CallToolResult; 'tools/list': ListToolsResult; - 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools | CreateTaskResult; - 'elicitation/create': ElicitResult | CreateTaskResult; + 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools; + 'elicitation/create': ElicitResult; 'roots/list': ListRootsResult; - 'tasks/get': GetTaskResult; - 'tasks/result': Result; - 'tasks/list': ListTasksResult; - 'tasks/cancel': CancelTaskResult; }; // @public (undocumented) @@ -6872,6 +6427,7 @@ export enum SdkErrorCode { NotInitialized = "NOT_INITIALIZED", RequestTimeout = "REQUEST_TIMEOUT", SendFailed = "SEND_FAILED", + UnsupportedResultType = "UNSUPPORTED_RESULT_TYPE", } // @public @@ -7606,7 +7162,7 @@ const ServerRequestSchema: z.ZodUnion]>; // @public (undocumented) -export type ServerResult = Infer; +export type ServerResult = StripWireOnly>; // @public (undocumented) const ServerResultSchema: z.ZodUnion = K extends `${infer N}Schema` ? N : never; +// @public +type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; + // @public (undocumented) export type SubscribeRequest = Infer; @@ -8496,13 +8055,13 @@ const SubscribeRequestSchema: z.ZodObject<{ }, z.core.$strip>; }, z.core.$strip>; -// @public (undocumented) +// @public @deprecated (undocumented) export type Task = Infer; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskAugmentedRequestParams = Infer; -// @public +// @public @deprecated const TaskAugmentedRequestParamsSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -8515,24 +8074,30 @@ const TaskAugmentedRequestParamsSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskCreationParams = Infer; -// @public +// @public @deprecated const TaskCreationParamsSchema: z.ZodObject<{ ttl: z.ZodOptional; pollInterval: z.ZodOptional; }, z.core.$loose>; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskMetadata = Infer; -// @public (undocumented) +// @public @deprecated (undocumented) const TaskMetadataSchema: z.ZodObject<{ ttl: z.ZodOptional; }, z.core.$strip>; +// @public (undocumented) +type TaskNotificationMethod = 'notifications/tasks/status'; + // @public +type TaskRequestMethod = 'tasks/get' | 'tasks/result' | 'tasks/list' | 'tasks/cancel'; + +// @public @deprecated const TaskSchema: z.ZodObject<{ taskId: z.ZodString; status: z.ZodEnum<{ @@ -8549,16 +8114,16 @@ const TaskSchema: z.ZodObject<{ statusMessage: z.ZodOptional; }, z.core.$strip>; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskStatus = Infer; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskStatusNotification = Infer; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskStatusNotificationParams = Infer; -// @public +// @public @deprecated const TaskStatusNotificationParamsSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -8581,7 +8146,7 @@ const TaskStatusNotificationParamsSchema: z.ZodObject<{ statusMessage: z.ZodOptional; }, z.core.$strip>; -// @public +// @public @deprecated const TaskStatusNotificationSchema: z.ZodObject<{ method: z.ZodLiteral<"notifications/tasks/status">; params: z.ZodObject<{ @@ -8607,7 +8172,7 @@ const TaskStatusNotificationSchema: z.ZodObject<{ }, z.core.$strip>; }, z.core.$strip>; -// @public +// @public @deprecated const TaskStatusSchema: z.ZodEnum<{ working: "working"; input_required: "input_required"; @@ -9015,6 +8580,9 @@ export class UrlElicitationRequiredError extends ProtocolError { // @public (undocumented) export type Variables = Record; +// @public +type WireOnlyResultKey = 'resultType'; + // @public export const applyMiddlewares: (...middleware: Middleware[]) => Middleware; @@ -9369,7 +8937,7 @@ export const isJSONRPCResultResponse: (value: unknown) => value is JSONRPCResult // @public export const isSpecType: GuardRecord; -// @public +// @public @deprecated export const isTaskAugmentedRequestParams: (value: unknown) => value is TaskAugmentedRequestParams; // @public diff --git a/packages/client/etc/client.stdio.api.md b/packages/client/etc/client.stdio.api.md index 8b5367b579..b3a5f16b34 100644 --- a/packages/client/etc/client.stdio.api.md +++ b/packages/client/etc/client.stdio.api.md @@ -28,10 +28,27 @@ type Flatten = T extends Primitive ? T : T extends Array ? Array = Flatten>; // @public (undocumented) -type JSONRPCMessage = Infer; +type JSONRPCErrorResponse = Infer; + +// @public +const JSONRPCErrorResponseSchema: z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; // @public (undocumented) -const JSONRPCMessageSchema: z.ZodUnion; + +// @public +const JSONRPCNotificationSchema: z.ZodObject<{ method: z.ZodString; params: z.ZodOptional>; }, z.core.$loose>>; jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>, z.ZodObject<{ +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCRequest = Infer; + +// @public +const JSONRPCRequestSchema: z.ZodObject<{ method: z.ZodString; params: z.ZodOptional>; }, z.core.$loose>>; jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>, z.ZodObject<{ + id: z.ZodUnion; +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCResultResponse = Omit, 'result'> & { + result: Result; +}; + +// @public +const JSONRPCResultResponseSchema: z.ZodObject<{ jsonrpc: z.ZodLiteral<"2.0">; id: z.ZodUnion; result: z.ZodObject<{ @@ -66,15 +97,7 @@ const JSONRPCMessageSchema: z.ZodUnion>; resultType: z.ZodOptional; }, z.core.$loose>; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>]>; +}, z.core.$strict>; // @public interface MessageExtraInfo { @@ -93,6 +116,20 @@ type RequestId = Infer; // @public const RequestIdSchema: z.ZodUnion; +// @public (undocumented) +type Result = StripWireOnly>; + +// @public (undocumented) +const ResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$loose>; + // @public export class StdioClientTransport implements Transport { constructor(server: StdioServerParameters); @@ -121,6 +158,9 @@ export type StdioServerParameters = { maxBufferSize?: number; }; +// @public +type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; + // @public interface Transport { close(): Promise; @@ -141,6 +181,9 @@ type TransportSendOptions = { onresumptiontoken?: ((token: string) => void) | undefined; }; +// @public +type WireOnlyResultKey = 'resultType'; + // @public export function getDefaultEnvironment(): Record; diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index bc3a91150b..2bc0acbdda 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -2,12 +2,16 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims' import type { BaseContext, CallToolRequest, + CallToolResult, ClientCapabilities, ClientContext, ClientNotification, ClientRequest, CompleteRequest, + CompleteResult, + EmptyResult, GetPromptRequest, + GetPromptResult, Implementation, JSONRPCRequest, JsonSchemaType, @@ -16,14 +20,19 @@ import type { ListChangedHandlers, ListChangedOptions, ListPromptsRequest, + ListPromptsResult, ListResourcesRequest, + ListResourcesResult, ListResourceTemplatesRequest, + ListResourceTemplatesResult, ListToolsRequest, + ListToolsResult, LoggingLevel, MessageExtraInfo, NotificationMethod, ProtocolOptions, ReadResourceRequest, + ReadResourceResult, RequestMethod, RequestOptions, Result, @@ -642,12 +651,12 @@ export class Client extends Protocol { } } - async ping(options?: RequestOptions) { + async ping(options?: RequestOptions): Promise { return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, options); } /** Requests argument autocompletion suggestions from the server for a prompt or resource. */ - async complete(params: CompleteRequest['params'], options?: RequestOptions) { + async complete(params: CompleteRequest['params'], options?: RequestOptions): Promise { return this._requestWithSchema({ method: 'completion/complete', params }, CompleteResultSchema, options); } @@ -658,12 +667,12 @@ export class Client extends Protocol { * Remains functional during the deprecation window (at least twelve months). * Migrate to stderr logging (STDIO servers) or OpenTelemetry. */ - async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { + async setLoggingLevel(level: LoggingLevel, options?: RequestOptions): Promise { return this._requestWithSchema({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); } /** Retrieves a prompt by name from the server, passing the given arguments for template substitution. */ - async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { + async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions): Promise { return this._requestWithSchema({ method: 'prompts/get', params }, GetPromptResultSchema, options); } @@ -689,7 +698,7 @@ export class Client extends Protocol { * ); * ``` */ - async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { + async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions): Promise { if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support prompts console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); @@ -720,7 +729,7 @@ export class Client extends Protocol { * ); * ``` */ - async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { + async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions): Promise { if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support resources console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); @@ -735,7 +744,10 @@ export class Client extends Protocol { * Returns an empty list if the server does not advertise resources capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). */ - async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { + async listResourceTemplates( + params?: ListResourceTemplatesRequest['params'], + options?: RequestOptions + ): Promise { if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support resources console.debug( @@ -747,17 +759,17 @@ export class Client extends Protocol { } /** Reads the contents of a resource by URI. */ - async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { + async readResource(params: ReadResourceRequest['params'], options?: RequestOptions): Promise { return this._requestWithSchema({ method: 'resources/read', params }, ReadResourceResultSchema, options); } /** Subscribes to change notifications for a resource. The server must support resource subscriptions. */ - async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { + async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions): Promise { return this._requestWithSchema({ method: 'resources/subscribe', params }, EmptyResultSchema, options); } /** Unsubscribes from change notifications for a resource. */ - async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { + async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions): Promise { return this._requestWithSchema({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); } @@ -798,7 +810,7 @@ export class Client extends Protocol { * } * ``` */ - async callTool(params: CallToolRequest['params'], options?: RequestOptions) { + async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise { const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); // Check if the tool has an outputSchema @@ -884,7 +896,7 @@ export class Client extends Protocol { * ); * ``` */ - async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { + async listTools(params?: ListToolsRequest['params'], options?: RequestOptions): Promise { if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support tools console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); diff --git a/packages/client/test/client/clientTypeSurface.test.ts b/packages/client/test/client/clientTypeSurface.test.ts new file mode 100644 index 0000000000..c6246a8fed --- /dev/null +++ b/packages/client/test/client/clientTypeSurface.test.ts @@ -0,0 +1,30 @@ +/** + * Type-surface pins for the client's high-level methods. + * + * `callTool` returns plain `CallToolResult` on every protocol era — no task + * union (a v2 client never sends a task-augmented call, so a task result is + * unreachable from its API) and no wire-only members (`resultType` is + * consumed at the protocol layer and never reaches consumers). + */ +import type { CallToolResult, EmptyResult, ListToolsResult, ReadResourceResult } from '@modelcontextprotocol/core'; +import { describe, expectTypeOf, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +type KnownKeyOf = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + +describe('client method return types', () => { + test('callTool returns plain CallToolResult (no union, no wire-only members)', () => { + type Return = Awaited>; + expectTypeOf().toEqualTypeOf(); + expectTypeOf, 'resultType'>>().toEqualTypeOf(); + expectTypeOf, 'task'>>().toEqualTypeOf(); + }); + + test('the other request methods return the public result types', () => { + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>, 'resultType'>>().toEqualTypeOf(); + }); +}); diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index af432c6389..808841807c 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -28,6 +28,12 @@ export enum SdkErrorCode { SendFailed = 'SEND_FAILED', /** Response result failed local schema validation */ InvalidResult = 'INVALID_RESULT', + /** + * The response carried a `resultType` discriminator (protocol revision + * 2026-07-28) naming a result kind this client cannot consume yet, e.g. + * `input_required`. The kind is carried in `data.resultType`. + */ + UnsupportedResultType = 'UNSUPPORTED_RESULT_TYPE', // Transport errors ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED', diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 16d2181018..5ab67c1546 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -24,6 +24,7 @@ import type { Request, RequestId, RequestMeta, + RequestMetaEnvelope, RequestMethod, RequestTypeMap, Result, @@ -31,6 +32,8 @@ import type { ServerCapabilities } from '../types/index.js'; import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, getNotificationSchema, getRequestSchema, getResultSchema, @@ -38,6 +41,8 @@ import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY, ProtocolError, ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS @@ -131,6 +136,88 @@ export type NotificationOptions = { relatedRequestId?: RequestId; }; +/** + * The reserved per-request `_meta` envelope keys (protocol revision + * 2026-07-28). The protocol layer lifts these out of inbound `_meta` before + * handlers run and surfaces them at `ctx.mcpReq.envelope` — they are + * wire-level bookkeeping, not handler material. + */ +const RESERVED_ENVELOPE_META_KEYS: readonly string[] = [ + PROTOCOL_VERSION_META_KEY, + CLIENT_INFO_META_KEY, + CLIENT_CAPABILITIES_META_KEY, + LOG_LEVEL_META_KEY +]; + +/** + * Top-level params members carrying multi-round-trip driver material + * (protocol revision 2026-07-28). The spec reserves these names on + * client-initiated REQUESTS only — notification params keep them untouched + * (a vendor notification may legitimately use the same names). + */ +const RETRY_PARAMS_KEYS = ['inputResponses', 'requestState'] as const; + +interface LiftedWireMaterial { + // Partial: the lift surfaces whichever reserved keys the request actually + // carried — a peer on an adjacent revision may legally send a subset, and + // envelope requiredness is enforced per request at dispatch time, not here. + envelope?: Partial; + inputResponses?: Record; + requestState?: string; +} + +/** + * Lift wire-only material out of an inbound message so handlers see exactly + * the 2025-era shape, and surface it for the protocol layer (requests: via + * `ctx.mcpReq`). What counts as wire-only depends on the message kind: the + * reserved envelope `_meta` keys are reserved on every message, while the + * multi-round-trip retry fields (`inputResponses`/`requestState`) are + * reserved on client-initiated requests only — so notifications get only the + * envelope lift, and their top-level params stay untouched. Messages without + * wire-only material are returned unchanged (same reference). + */ +function liftWireOnlyMaterial( + message: T, + kind: 'request' | 'notification' +): { message: T; lifted: LiftedWireMaterial } { + const params = (message as { params?: unknown }).params; + if (!isPlainObject(params)) return { message, lifted: {} }; + + const meta = params._meta; + const envelopeKeys = isPlainObject(meta) ? RESERVED_ENVELOPE_META_KEYS.filter(key => key in meta) : []; + const retryKeys = kind === 'request' ? RETRY_PARAMS_KEYS.filter(key => key in params) : []; + if (envelopeKeys.length === 0 && retryKeys.length === 0) return { message, lifted: {} }; + + const lifted: LiftedWireMaterial = {}; + const nextParams: Record = { ...params }; + + if (envelopeKeys.length > 0 && isPlainObject(meta)) { + const envelope: Record = {}; + const nextMeta: Record = { ...meta }; + for (const key of envelopeKeys) { + envelope[key] = meta[key]; + delete nextMeta[key]; + } + // Surfaced as received; validation/enforcement is the dispatch-time + // classifier's job, not the lift's. + lifted.envelope = envelope as Partial; + if (Object.keys(nextMeta).length > 0) { + nextParams._meta = nextMeta; + } else { + delete nextParams._meta; + } + } + + for (const key of retryKeys) { + // Driver material reaches the protocol layer un-deleted, verbatim. + if (key === 'inputResponses') lifted.inputResponses = nextParams[key] as Record; + if (key === 'requestState') lifted.requestState = nextParams[key] as string; + delete nextParams[key]; + } + + return { message: { ...message, params: nextParams } as T, lifted }; +} + /** * Base context provided to all request handlers. */ @@ -155,10 +242,37 @@ export type BaseContext = { method: string; /** - * Metadata from the original request. + * Metadata from the original request, with the reserved + * `io.modelcontextprotocol/*` envelope keys already lifted out + * (readable via `ctx.mcpReq.envelope`). */ _meta?: RequestMeta; + /** + * The per-request `_meta` envelope (protocol revision 2026-07-28): + * the reserved `io.modelcontextprotocol/*` keys carried by the + * request, lifted out of the `_meta` the handler sees. Surfaced as + * received — `Partial` because only the keys the request actually + * carried are present (envelope requiredness is enforced per request + * at dispatch time, not by the lift); only present at all when the + * request carried envelope keys. + */ + envelope?: Partial; + + /** + * Multi-round-trip input responses carried by a retried request + * (protocol revision 2026-07-28), lifted out of the params the + * handler sees. Driver material — present verbatim when sent. + */ + inputResponses?: Record; + + /** + * Multi-round-trip request state echoed by a retried request + * (protocol revision 2026-07-28), lifted out of the params the + * handler sees. Driver material — present verbatim when sent. + */ + requestState?: string; + /** * An abort signal used to communicate if the request was cancelled from the sender's side. */ @@ -470,7 +584,13 @@ export abstract class Protocol { this.onerror?.(error); } - private _onnotification(notification: JSONRPCNotification): void { + private _onnotification(rawNotification: JSONRPCNotification): void { + // Hide wire-only material from notification handlers too — but ONLY + // the reserved envelope `_meta` keys (the retry params names are + // reserved on requests, not notifications). There is no + // per-notification context, so the lifted envelope keys are dropped, + // not surfaced; the protocol layer owns them. + const { message: notification } = liftWireOnlyMaterial(rawNotification, 'notification'); const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; // Ignore notifications not being subscribed to. @@ -484,7 +604,11 @@ export abstract class Protocol { .catch(error => this._onerror(new Error(`Uncaught error in notification handler: ${error}`))); } - private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void { + private _onrequest(rawRequest: JSONRPCRequest, extra?: MessageExtraInfo): void { + // Lift wire-only material before dispatch: handlers (including the + // fallback handler and the per-method schema parse) see exactly the + // 2025-era shape; the envelope and retry fields surface via ctx. + const { message: request, lifted } = liftWireOnlyMaterial(rawRequest, 'request'); const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; // Capture the current transport at request time to ensure responses go to the correct client @@ -517,6 +641,9 @@ export abstract class Protocol { id: request.id, method: request.method, _meta: request.params?._meta, + ...(lifted.envelope !== undefined && { envelope: lifted.envelope }), + ...(lifted.inputResponses !== undefined && { inputResponses: lifted.inputResponses }), + ...(lifted.requestState !== undefined && { requestState: lifted.requestState }), signal: abortController.signal, // BaseContext.mcpReq.send is declared with two overloads (spec-method-keyed and explicit-schema). Arrow // literals can't carry overload signatures, so the inferred single-signature type isn't assignable to @@ -789,7 +916,50 @@ export abstract class Protocol { return reject(response); } - validateStandardSchema(resultSchema, response.result).then(parseResult => { + // Raw-first result discrimination (protocol revision + // 2026-07-28): inspect `resultType` BEFORE any schema + // validation, so a non-complete result can never be masked + // into a hollow success by a tolerant result schema (e.g. + // defaults filling in absent members). + let result = response.result; + if (isPlainObject(result) && result['resultType'] !== undefined) { + const rawResultType = result['resultType']; + if (typeof rawResultType !== 'string') { + // Defense in depth, not a reachable rejection today: + // the wire schema types `resultType` as a string, so + // message classification rejects a non-string carrier + // before it can reach this funnel (the request then + // hangs until timeout — the pre-existing failure mode + // for malformed responses). The arm stays so the + // raw-first check is self-contained if classification + // ever loosens. + return reject( + new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: non-string resultType`, { + resultType: rawResultType + }) + ); + } + if (rawResultType !== 'complete') { + // Surface the discriminated kind; no retry. This arm + // is replaced by full multi-round-trip handling when + // the client driver lands. + return reject( + new SdkError( + SdkErrorCode.UnsupportedResultType, + `Unsupported result type '${rawResultType}' for ${request.method}`, + { resultType: rawResultType, method: request.method } + ) + ); + } + // 'complete': the SDK consumes the wire discriminator; + // strip it before validation so consumers receive the + // public result shape. + const rest = { ...result }; + delete rest['resultType']; + result = rest as Result; + } + + validateStandardSchema(resultSchema, result).then(parseResult => { if (parseResult.success) { resolve(parseResult.data); } else { @@ -978,7 +1148,9 @@ export abstract class Protocol { * spec schema. For custom (non-spec) methods, pass `(method, schemas, handler)`; * `params` are validated against `schemas.params` and the handler receives the * parsed params object directly. The raw notification is passed as the second - * argument; `_meta` is recoverable via `notification.params?._meta`. + * argument; `_meta` is recoverable via `notification.params?._meta` (minus the + * reserved `io.modelcontextprotocol/*` envelope keys, which the protocol layer + * lifts out before dispatch). */ setNotificationHandler( method: M, diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index 018f9ecb51..109c5c4ee2 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -2,6 +2,11 @@ export const LATEST_PROTOCOL_VERSION = '2025-11-25'; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26'; export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; +/** + * `_meta` key associating a message with a 2025-11-25 task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; /* Reserved `_meta` keys for the per-request envelope (protocol revision 2026-07-28) */ diff --git a/packages/core/src/types/guards.ts b/packages/core/src/types/guards.ts index f385b91b42..dd5c4765a1 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -86,6 +86,9 @@ export const isCallToolResult = (value: unknown): value is CallToolResult => { * @param value - The value to check. * * @returns True if the value is a valid {@linkcode TaskAugmentedRequestParams}, false otherwise. + * + * @deprecated Recognizes 2025-11-25 task wire vocabulary, which has no SDK + * runtime; kept importable for interoperability only. */ export const isTaskAugmentedRequestParams = (value: unknown): value is TaskAugmentedRequestParams => TaskAugmentedRequestParamsSchema.safeParse(value).success; diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index fe850284e2..5c9d4a3f5e 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -36,6 +36,8 @@ export const CursorSchema = z.string(); /** * Task creation parameters, used to ask that the server create a task to represent a request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const TaskCreationParamsSchema = z.looseObject({ /** @@ -49,6 +51,7 @@ export const TaskCreationParamsSchema = z.looseObject({ pollInterval: z.number().optional() }); +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const TaskMetadataSchema = z.object({ ttl: z.number().optional() }); @@ -56,6 +59,8 @@ export const TaskMetadataSchema = z.object({ /** * Metadata for associating messages with a task. * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const RelatedTaskMetadataSchema = z.object({ taskId: z.string() @@ -84,6 +89,8 @@ export const BaseRequestParamsSchema = z.object({ /** * Common params for any task-augmented request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const TaskAugmentedRequestParamsSchema = BaseRequestParamsSchema.extend({ /** @@ -347,6 +354,8 @@ const ElicitationCapabilitySchema = z.preprocess( /** * Task capabilities for clients, indicating which request types support task creation. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const ClientTasksCapabilitySchema = z.looseObject({ /** @@ -384,6 +393,8 @@ export const ClientTasksCapabilitySchema = z.looseObject({ /** * Task capabilities for servers, indicating which request types support task creation. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const ServerTasksCapabilitySchema = z.looseObject({ /** @@ -460,6 +471,8 @@ export const ClientCapabilitiesSchema = z.object({ .optional(), /** * Present if the client supports task creation. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; parsed for interoperability only — servers built on this SDK never advertise it. */ tasks: ClientTasksCapabilitySchema.optional(), /** @@ -544,6 +557,8 @@ export const ServerCapabilitiesSchema = z.object({ .optional(), /** * Present if the server supports task creation. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; parsed for interoperability only — servers built on this SDK never advertise it. */ tasks: ServerTasksCapabilitySchema.optional(), /** @@ -681,12 +696,16 @@ export const PaginatedResultSchema = ResultSchema.extend({ /** * The status of a task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. * */ export const TaskStatusSchema = z.enum(['working', 'input_required', 'completed', 'failed', 'cancelled']); /* Tasks */ /** * A pollable state object associated with a request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const TaskSchema = z.object({ taskId: z.string(), @@ -713,6 +732,8 @@ export const TaskSchema = z.object({ /** * Result returned when a task is created, containing the task data wrapped in a `task` field. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const CreateTaskResultSchema = ResultSchema.extend({ task: TaskSchema @@ -720,11 +741,15 @@ export const CreateTaskResultSchema = ResultSchema.extend({ /** * Parameters for task status notification. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); /** * A notification sent when a task's status changes. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const TaskStatusNotificationSchema = NotificationSchema.extend({ method: z.literal('notifications/tasks/status'), @@ -733,6 +758,8 @@ export const TaskStatusNotificationSchema = NotificationSchema.extend({ /** * A request to get the state of a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const GetTaskRequestSchema = RequestSchema.extend({ method: z.literal('tasks/get'), @@ -743,11 +770,15 @@ export const GetTaskRequestSchema = RequestSchema.extend({ /** * The response to a {@linkcode GetTaskRequest | tasks/get} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const GetTaskResultSchema = ResultSchema.merge(TaskSchema); /** * A request to get the result of a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const GetTaskPayloadRequestSchema = RequestSchema.extend({ method: z.literal('tasks/result'), @@ -761,11 +792,14 @@ export const GetTaskPayloadRequestSchema = RequestSchema.extend({ * The structure matches the result type of the original request. * For example, a {@linkcode CallToolRequest | tools/call} task would return the `CallToolResult` structure. * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const GetTaskPayloadResultSchema = ResultSchema.loose(); /** * A request to list tasks. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ method: z.literal('tasks/list') @@ -773,6 +807,8 @@ export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ /** * The response to a {@linkcode ListTasksRequest | tasks/list} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const ListTasksResultSchema = PaginatedResultSchema.extend({ tasks: z.array(TaskSchema) @@ -780,6 +816,8 @@ export const ListTasksResultSchema = PaginatedResultSchema.extend({ /** * A request to cancel a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const CancelTaskRequestSchema = RequestSchema.extend({ method: z.literal('tasks/cancel'), @@ -790,6 +828,8 @@ export const CancelTaskRequestSchema = RequestSchema.extend({ /** * The response to a {@linkcode CancelTaskRequest | tasks/cancel} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); @@ -2261,7 +2301,13 @@ export const ServerResultSchema = z.union([ ]); /* Runtime schema lookup — result schemas by method */ -const resultSchemas: Record = { +// Keyed by `RequestMethod` so the runtime map and the typed `ResultTypeMap` +// cannot drift: `getResultSchema`'s typed overload asserts each entry parses +// to `ResultTypeMap[M]`, so no entry may be looser than the typed map +// (no task-result union members) and no key may fall outside it (no `tasks/*` +// entries — the task methods are 2025-11-25 wire vocabulary with no SDK +// runtime; callers needing task interop pass an explicit schema). +const resultSchemas: Record = { ping: EmptyResultSchema, initialize: InitializeResultSchema, 'completion/complete': CompleteResultSchema, @@ -2273,15 +2319,11 @@ const resultSchemas: Record = { 'resources/read': ReadResourceResultSchema, 'resources/subscribe': EmptyResultSchema, 'resources/unsubscribe': EmptyResultSchema, - 'tools/call': z.union([CallToolResultSchema, CreateTaskResultSchema]), + 'tools/call': CallToolResultSchema, 'tools/list': ListToolsResultSchema, - 'sampling/createMessage': z.union([CreateMessageResultWithToolsSchema, CreateTaskResultSchema]), - 'elicitation/create': z.union([ElicitResultSchema, CreateTaskResultSchema]), - 'roots/list': ListRootsResultSchema, - 'tasks/get': GetTaskResultSchema, - 'tasks/result': ResultSchema, - 'tasks/list': ListTasksResultSchema, - 'tasks/cancel': CancelTaskResultSchema + 'sampling/createMessage': CreateMessageResultWithToolsSchema, + 'elicitation/create': ElicitResultSchema, + 'roots/list': ListRootsResultSchema }; /** diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index e538da8fa5..9da6a2f4a8 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -223,7 +223,11 @@ export type SpecTypeName = StripSchemaSuffix; /** * Maps each {@linkcode SpecTypeName} to its TypeScript type. * - * `SpecTypes['CallToolResult']` is equivalent to importing the `CallToolResult` type directly. + * `SpecTypes['Tool']` is equivalent to importing the `Tool` type directly. + * These are WIRE validator outputs: result entries additionally carry the + * wire-only `resultType` member, which the public result types do not declare + * (the SDK consumes it at the protocol layer and strips it before results + * reach consumers). */ export type SpecTypes = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.output> : never; diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index e0fe28b500..30b91671ab 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -62,10 +62,8 @@ import type { InitializeRequestSchema, InitializeResultSchema, JSONRPCErrorResponseSchema, - JSONRPCMessageSchema, JSONRPCNotificationSchema, JSONRPCRequestSchema, - JSONRPCResponseSchema, JSONRPCResultResponseSchema, LegacyTitledEnumSchemaSchema, ListPromptsRequestSchema, @@ -186,21 +184,41 @@ type Flatten = T extends Primitive type Infer = Flatten>; +/** + * Wire-only members hidden from the public types. + * + * `resultType` is the protocol-revision-2026-07-28 wire discrimination field + * on results. It is consumed by the SDK's protocol layer (and stripped before + * results reach consumers), so the public result types do not declare it. + * The wire schemas continue to model it internally. + */ +type WireOnlyResultKey = 'resultType'; + +/** + * Removes wire-only members from a (possibly union) schema-inferred type + * while preserving every other declared member, optionality, and the loose + * index signature. + */ +type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; + /* JSON-RPC types */ export type ProgressToken = Infer; export type Cursor = Infer; export type Request = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskAugmentedRequestParams = Infer; export type RequestMeta = Infer; export type Notification = Infer; -export type Result = Infer; +export type Result = StripWireOnly>; export type RequestId = Infer; export type JSONRPCRequest = Infer; export type JSONRPCNotification = Infer; -export type JSONRPCResponse = Infer; export type JSONRPCErrorResponse = Infer; -export type JSONRPCResultResponse = Infer; -export type JSONRPCMessage = Infer; +// The response/message envelopes embed result objects, so they are rebuilt +// from the public (wire-only-stripped) `Result` rather than schema-inferred. +export type JSONRPCResultResponse = Omit, 'result'> & { result: Result }; +export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; +export type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; export type RequestParams = Infer; export type NotificationParams = Infer; /** @@ -210,7 +228,7 @@ export type NotificationParams = Infer; export type RequestMetaEnvelope = Infer; /* Empty result */ -export type EmptyResult = Infer; +export type EmptyResult = StripWireOnly>; /* Cancellation */ export type CancelledNotificationParams = Infer; @@ -243,12 +261,12 @@ export type InitializeRequest = Infer; * months. See `ServerCapabilitiesSchema`. */ export type ServerCapabilities = Infer; -export type InitializeResult = Infer; +export type InitializeResult = StripWireOnly>; export type InitializedNotification = Infer; /* Discovery */ export type DiscoverRequest = Infer; -export type DiscoverResult = Infer; +export type DiscoverResult = StripWireOnly>; /* Ping */ export type PingRequest = Infer; @@ -258,28 +276,52 @@ export type Progress = Infer; export type ProgressNotificationParams = Infer; export type ProgressNotification = Infer; -/* Tasks */ +/* Tasks + * + * The task wire surface defined by the 2025-11-25 protocol revision. These + * types stay importable as wire vocabulary for interoperability with peers on + * that revision, but they appear in no SDK API signature: the SDK has no task + * runtime, and the typed method maps (RequestMethod/RequestTypeMap/ + * ResultTypeMap/NotificationTypeMap) do not include the task methods. + * Removable at the major version that drops 2025-era support. + */ +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type Task = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskStatus = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskCreationParams = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskMetadata = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type RelatedTaskMetadata = Infer; -export type CreateTaskResult = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export type CreateTaskResult = StripWireOnly>; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskStatusNotificationParams = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskStatusNotification = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type GetTaskRequest = Infer; -export type GetTaskResult = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export type GetTaskResult = StripWireOnly>; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type GetTaskPayloadRequest = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type ListTasksRequest = Infer; -export type ListTasksResult = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export type ListTasksResult = StripWireOnly>; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type CancelTaskRequest = Infer; -export type CancelTaskResult = Infer; -export type GetTaskPayloadResult = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export type CancelTaskResult = StripWireOnly>; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export type GetTaskPayloadResult = StripWireOnly>; /* Pagination */ export type PaginatedRequestParams = Infer; export type PaginatedRequest = Infer; -export type PaginatedResult = Infer; +export type PaginatedResult = StripWireOnly>; /* Resources */ export type ResourceContents = Infer; @@ -289,13 +331,13 @@ export type Resource = Infer; // TODO: Overlaps with exported `ResourceTemplate` class from `server`. export type ResourceTemplateType = Infer; export type ListResourcesRequest = Infer; -export type ListResourcesResult = Infer; +export type ListResourcesResult = StripWireOnly>; export type ListResourceTemplatesRequest = Infer; -export type ListResourceTemplatesResult = Infer; +export type ListResourceTemplatesResult = StripWireOnly>; export type ResourceRequestParams = Infer; export type ReadResourceRequestParams = Infer; export type ReadResourceRequest = Infer; -export type ReadResourceResult = Infer; +export type ReadResourceResult = StripWireOnly>; export type ResourceListChangedNotification = Infer; export type SubscribeRequestParams = Infer; export type SubscribeRequest = Infer; @@ -308,7 +350,7 @@ export type ResourceUpdatedNotification = Infer; export type Prompt = Infer; export type ListPromptsRequest = Infer; -export type ListPromptsResult = Infer; +export type ListPromptsResult = StripWireOnly>; export type GetPromptRequestParams = Infer; export type GetPromptRequest = Infer; export type TextContent = Infer; @@ -320,7 +362,7 @@ export type EmbeddedResource = Infer; export type ResourceLink = Infer; export type ContentBlock = Infer; export type PromptMessage = Infer; -export type GetPromptResult = Infer; +export type GetPromptResult = StripWireOnly>; export type PromptListChangedNotification = Infer; /* Tools */ @@ -328,10 +370,10 @@ export type ToolAnnotations = Infer; export type ToolExecution = Infer; export type Tool = Infer; export type ListToolsRequest = Infer; -export type ListToolsResult = Infer; +export type ListToolsResult = StripWireOnly>; export type CallToolRequestParams = Infer; -export type CallToolResult = Infer; -export type CompatibilityCallToolResult = Infer; +export type CallToolResult = StripWireOnly>; +export type CompatibilityCallToolResult = StripWireOnly>; export type CallToolRequest = Infer; export type ToolListChangedNotification = Infer; @@ -351,8 +393,8 @@ export type SamplingMessageContentBlock = Infer; export type CreateMessageRequestParams = Infer; export type CreateMessageRequest = Infer; -export type CreateMessageResult = Infer; -export type CreateMessageResultWithTools = Infer; +export type CreateMessageResult = StripWireOnly>; +export type CreateMessageResultWithTools = StripWireOnly>; /* Elicitation */ export type BooleanSchema = Infer; @@ -373,39 +415,48 @@ export type ElicitRequestURLParams = Infer; export type ElicitRequest = Infer; export type ElicitationCompleteNotificationParams = Infer; export type ElicitationCompleteNotification = Infer; -export type ElicitResult = Infer; +export type ElicitResult = StripWireOnly>; /* Autocomplete */ export type ResourceTemplateReference = Infer; export type PromptReference = Infer; export type CompleteRequestParams = Infer; export type CompleteRequest = Infer; -export type CompleteResult = Infer; +export type CompleteResult = StripWireOnly>; /* Roots */ export type Root = Infer; export type ListRootsRequest = Infer; -export type ListRootsResult = Infer; +export type ListRootsResult = StripWireOnly>; export type RootsListChangedNotification = Infer; /* Client messages */ export type ClientRequest = Infer; export type ClientNotification = Infer; -export type ClientResult = Infer; +export type ClientResult = StripWireOnly>; /* Server messages */ export type ServerRequest = Infer; export type ServerNotification = Infer; -export type ServerResult = Infer; +export type ServerResult = StripWireOnly>; /* Protocol type maps */ type MethodToTypeMap = { [T in U as T extends { method: infer M extends string } ? M : never]: T; }; -export type RequestMethod = ClientRequest['method'] | ServerRequest['method']; -export type NotificationMethod = ClientNotification['method'] | ServerNotification['method']; -export type RequestTypeMap = MethodToTypeMap; -export type NotificationTypeMap = MethodToTypeMap; +/** + * Task methods are 2025-11-25 wire vocabulary with no SDK runtime: the task + * wire types stay importable (see the Tasks section above), but the typed + * method surface — `request()`, `setRequestHandler()`, `ctx.mcpReq.send()` — + * does not offer them. The wire schemas keep parsing task vocabulary for + * interoperability with 2025-11-25 peers. + */ +type TaskRequestMethod = 'tasks/get' | 'tasks/result' | 'tasks/list' | 'tasks/cancel'; +type TaskNotificationMethod = 'notifications/tasks/status'; +export type RequestMethod = Exclude; +export type NotificationMethod = Exclude; +export type RequestTypeMap = MethodToTypeMap>; +export type NotificationTypeMap = MethodToTypeMap>; export type ResultTypeMap = { ping: EmptyResult; initialize: InitializeResult; @@ -418,15 +469,11 @@ export type ResultTypeMap = { 'resources/read': ReadResourceResult; 'resources/subscribe': EmptyResult; 'resources/unsubscribe': EmptyResult; - 'tools/call': CallToolResult | CreateTaskResult; + 'tools/call': CallToolResult; 'tools/list': ListToolsResult; - 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools | CreateTaskResult; - 'elicitation/create': ElicitResult | CreateTaskResult; + 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools; + 'elicitation/create': ElicitResult; 'roots/list': ListRootsResult; - 'tasks/get': GetTaskResult; - 'tasks/result': Result; - 'tasks/list': ListTasksResult; - 'tasks/cancel': CancelTaskResult; }; /** diff --git a/packages/core/test/shared/rawResultTypeFirst.test.ts b/packages/core/test/shared/rawResultTypeFirst.test.ts new file mode 100644 index 0000000000..6ccec21b95 --- /dev/null +++ b/packages/core/test/shared/rawResultTypeFirst.test.ts @@ -0,0 +1,151 @@ +/** + * Raw-first result discrimination: the client funnel inspects the raw + * `resultType` member BEFORE any schema validation. + * + * The hazard this closes: tolerant result schemas (defaults filling absent + * members, loose passthrough) would otherwise mask a non-complete result — + * e.g. an `input_required` body parsing as a successful empty tool result. + * The raw check runs first, so: + * + * - `input_required` (or any non-`complete` kind) → typed local error + * carrying the discriminated kind; never a hollow success; no retry. + * - non-string `resultType` → typed invalid-result error (checked raw, + * before any schema could coerce or tolerate it). + * - `'complete'` → the discriminator is consumed (stripped) and the result + * parses as the public shape. + * - absent → untouched (2025-era behavior, byte-identical). + */ +import { describe, expect, test } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import type { JSONRPCRequest } from '../../src/types/index.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +/** Wire a protocol whose peer answers every request with the given raw result body. */ +async function wireWithRawResult(rawResult: unknown): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + void serverTx.send({ jsonrpc: '2.0', id: request.id, result: rawResult } as Parameters[0]); + }; + await serverTx.start(); + const protocol = new TestProtocol(); + await protocol.connect(clientTx); + return protocol; +} + +describe('raw-first resultType discrimination in the request funnel', () => { + test('an input_required body surfaces the discriminated kind, never an empty-content success', async () => { + // The exact masking hazard: tools/call's result schema defaults + // content to [] — without the raw-first check this body would + // resolve as { content: [] }. + const protocol = await wireWithRawResult({ + resultType: 'input_required', + inputRequests: { 'elicit-1': { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } }, + requestState: 'opaque' + }); + + const outcome = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }).then( + result => ({ resolved: result as unknown }), + error => ({ rejected: error as unknown }) + ); + + expect('resolved' in outcome, 'must not resolve as a success').toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + const typed = rejection as SdkError; + expect(typed.code).toBe(SdkErrorCode.UnsupportedResultType); + expect(typed.data).toMatchObject({ resultType: 'input_required', method: 'tools/call' }); + + await protocol.close(); + }); + + test('an unrecognized resultType kind is invalid — surfaced, no retry', async () => { + const protocol = await wireWithRawResult({ resultType: 'mystery-kind', content: [] }); + + const rejection = await protocol + .request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.UnsupportedResultType); + expect((rejection as SdkError).data).toMatchObject({ resultType: 'mystery-kind' }); + + await protocol.close(); + }); + + test('a non-string resultType can never surface as a success (rejected at message classification)', async () => { + // A response whose resultType is not a string fails the JSON-RPC + // envelope classification (the wire schema types the member), so it + // is reported out-of-band and never reaches the result funnel — and + // can therefore never be masked into a success. The funnel keeps a + // defensive raw-type check for the day classification loosens. + const protocol = await wireWithRawResult({ resultType: 42, content: [] }); + const outOfBand: Error[] = []; + protocol.onerror = error => void outOfBand.push(error); + + let settled: unknown; + const pending = protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }).then( + result => { + settled = { resolved: result }; + }, + error => { + settled = { rejected: error }; + } + ); + + await new Promise(resolve => setTimeout(resolve, 50)); + expect(settled, 'must not resolve as a success').toBeUndefined(); + expect(outOfBand.length).toBeGreaterThan(0); + expect(String(outOfBand[0]?.message)).toContain('Unknown message type'); + + // Teardown settles the in-flight request (connection closed). + await protocol.close(); + await pending; + expect(settled).toHaveProperty('rejected'); + }); + + test("resultType 'complete' is consumed: the result resolves without the wire member", async () => { + const protocol = await wireWithRawResult({ + resultType: 'complete', + content: [{ type: 'text', text: 'done' }] + }); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + expect(result.content).toEqual([{ type: 'text', text: 'done' }]); + expect('resultType' in result).toBe(false); + + await protocol.close(); + }); + + test("resultType 'complete' on a strict empty result still parses (stripped before validation)", async () => { + const protocol = await wireWithRawResult({ resultType: 'complete' }); + + // EmptyResultSchema is strict; the discriminator is consumed before + // validation, so the 2026-era ack parses as the public empty result. + const result = await protocol.request({ method: 'ping' }); + expect(result).toEqual({}); + + await protocol.close(); + }); + + test('absent resultType is untouched 2025-era behavior', async () => { + const protocol = await wireWithRawResult({ content: [{ type: 'text', text: 'plain' }], extra: 'kept' }); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + expect(result.content).toEqual([{ type: 'text', text: 'plain' }]); + expect((result as Record).extra).toBe('kept'); + + await protocol.close(); + }); +}); diff --git a/packages/core/test/shared/typedMapAlignment.test.ts b/packages/core/test/shared/typedMapAlignment.test.ts new file mode 100644 index 0000000000..acd667c6cc --- /dev/null +++ b/packages/core/test/shared/typedMapAlignment.test.ts @@ -0,0 +1,132 @@ +/** + * Runtime/typed result-map alignment. + * + * `getResultSchema`'s typed overload asserts `z.ZodType`, + * so the runtime map must not be looser than the typed map: no task-result + * union members on `tools/call` / `sampling/createMessage` / + * `elicitation/create` (ResultTypeMap types them plain), and no `tasks/*` + * entries at all (the task methods are 2025-11-25 wire vocabulary outside + * `RequestMethod`). + * + * The behavioral consequence for a generic `request()` caller facing a + * 2025-era task server: a `CreateTaskResult` body can no longer parse via a + * union member and surface mis-typed (a `CreateTaskResult` typed as + * `CreateMessageResult`/`ElicitResult`). Where the method's result schema + * rejects the body it now fails as a typed invalid-result error. This client + * cannot drive tasks; a typed error is the correct surface, not a result + * whose static type lies. + */ +import { describe, expect, test } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import type { JSONRPCRequest } from '../../src/types/index.js'; +import { getResultSchema } from '../../src/types/index.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +/** A well-formed 2025-11-25 `CreateTaskResult` body. */ +const CREATE_TASK_RESULT_BODY = { + task: { + taskId: 'task-1', + status: 'working', + ttl: 60_000, + createdAt: '2025-11-25T00:00:00Z', + lastUpdatedAt: '2025-11-25T00:00:00Z', + pollInterval: 500 + } +}; + +/** Wire a protocol whose peer answers every request with the given raw result body. */ +async function wireWithRawResult(rawResult: unknown): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + void serverTx.send({ jsonrpc: '2.0', id: request.id, result: rawResult } as Parameters[0]); + }; + await serverTx.start(); + const protocol = new TestProtocol(); + await protocol.connect(clientTx); + return protocol; +} + +describe('task-shaped result bodies against the narrowed runtime map', () => { + test('sampling/createMessage: a CreateTaskResult body is a typed invalid-result error, not a mis-typed success', async () => { + // Before the narrowing, the union member parsed this body and handed + // it back TYPED as CreateMessageResult — a result whose static type + // lies. Now it fails the (plain) result schema locally. + const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); + + const outcome = await protocol.request({ method: 'sampling/createMessage', params: { messages: [], maxTokens: 1 } }).then( + result => ({ resolved: result as unknown }), + error => ({ rejected: error as unknown }) + ); + + expect('resolved' in outcome, 'must not resolve as a success').toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); + + await protocol.close(); + }); + + test('elicitation/create: a CreateTaskResult body is a typed invalid-result error, not a mis-typed success', async () => { + const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); + + const rejection = await protocol + .request({ method: 'elicitation/create', params: { mode: 'form', message: 'Name?', requestedSchema: { type: 'object' } } }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); + + await protocol.close(); + }); + + test('tools/call: the tolerant result schema still accepts the body (pre-existing; the old union member was unreachable)', async () => { + // Honest pin, not an endorsement: CallToolResultSchema defaults + // `content` to [] and is loose, so it accepts ANY object — including + // a task body. That made the old union's CreateTaskResultSchema + // member unreachable for tools/call (first member always matched), + // so the narrowing changes nothing observable here; the body parses + // as a content-empty CallToolResult with `task` passing through the + // loose index signature, exactly as before. Rejecting it is a result- + // schema-strictness question, out of scope for the map alignment. + const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + expect(result.content).toEqual([]); + expect((result as Record).task).toEqual(CREATE_TASK_RESULT_BODY.task); + + await protocol.close(); + }); +}); + +describe('tasks/* entries are gone from the runtime result map', () => { + test('getResultSchema returns undefined for every task method', () => { + for (const method of ['tasks/get', 'tasks/result', 'tasks/list', 'tasks/cancel']) { + expect(getResultSchema(method), method).toBeUndefined(); + } + }); + + test('a generic request() for a task method demands an explicit schema', async () => { + // The typed overload already excluded task methods; the runtime map + // entries were typed-unreachable leftovers. Without them, the + // explicit-schema overload is the one (intentional) interop path. + const protocol = await wireWithRawResult({}); + + expect(() => protocol.request({ method: 'tasks/get', params: { taskId: 't-1' } } as never)).toThrow( + /'tasks\/get' is not a spec method; pass a result schema/ + ); + + await protocol.close(); + }); +}); diff --git a/packages/core/test/shared/wireOnlyLift.test.ts b/packages/core/test/shared/wireOnlyLift.test.ts new file mode 100644 index 0000000000..32a8eb5302 --- /dev/null +++ b/packages/core/test/shared/wireOnlyLift.test.ts @@ -0,0 +1,329 @@ +/** + * Envelope lift, two-sided: wire-only material is hidden from handlers AND + * (for requests) reaches the protocol layer un-deleted. + * + * Hide set, per message kind. Requests: the reserved + * `io.modelcontextprotocol/*` envelope `_meta` keys and the multi-round-trip + * retry fields (`inputResponses`/`requestState`) — the envelope is readable + * via `ctx.mcpReq.envelope` and the retry fields via + * `ctx.mcpReq.inputResponses`/`.requestState`. Notifications: ONLY the + * envelope `_meta` keys (the spec reserves the retry params names on + * client-initiated requests, not notifications), and there is no + * per-notification ctx, so the lifted envelope keys are dropped rather than + * surfaced. Under 2026-era traffic, handler params must be byte-equal to the + * 2025-era shape of the same call; traffic without wire-only material passes + * through untouched (same reference — no cloning on the hot path). + */ +import { describe, expect, expectTypeOf, test } from 'vitest'; +import * as z from 'zod/v4'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import type { JSONRPCMessage, JSONRPCRequest, RequestMetaEnvelope, Result } from '../../src/types/index.js'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY, + RELATED_TASK_META_KEY +} from '../../src/types/index.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: {} }, + [LOG_LEVEL_META_KEY]: 'info' +}; + +interface Wired { + receiver: TestProtocol; + peer: InMemoryTransport; + responses: JSONRPCMessage[]; +} + +async function wireReceiver(setup: (receiver: TestProtocol) => void): Promise { + const [peer, receiverTx] = InMemoryTransport.createLinkedPair(); + const receiver = new TestProtocol(); + setup(receiver); + await receiver.connect(receiverTx); + const responses: JSONRPCMessage[] = []; + peer.onmessage = message => void responses.push(message); + await peer.start(); + return { receiver, peer, responses }; +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 20)); + +describe('envelope lift on inbound requests', () => { + test('handler params are byte-equal to the 2025 shape; envelope readable via ctx', async () => { + let seenRequest: unknown; + let seenCtx: BaseContext | undefined; + const { peer } = await wireReceiver(receiver => { + receiver.setRequestHandler('tools/call', (request, ctx) => { + seenRequest = request; + seenCtx = ctx; + return { content: [] }; + }); + }); + + // A modern request: envelope keys ride _meta next to 2025-legal + // material (progressToken, related-task). + await peer.send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'echo', + arguments: { text: 'hi' }, + _meta: { ...ENVELOPE, progressToken: 7, [RELATED_TASK_META_KEY]: { taskId: 't-1' } } + } + } as JSONRPCMessage); + await flush(); + + // Byte-equal to the 2025-era shape of the same call (the spec-method + // handler receives the schema-parsed {method, params} form). + expect(seenRequest).toEqual({ + method: 'tools/call', + params: { + name: 'echo', + arguments: { text: 'hi' }, + _meta: { progressToken: 7, [RELATED_TASK_META_KEY]: { taskId: 't-1' } } + } + }); + // ctx._meta mirrors the lifted _meta… + expect(seenCtx?.mcpReq._meta).toEqual({ progressToken: 7, [RELATED_TASK_META_KEY]: { taskId: 't-1' } }); + // …and the envelope is surfaced verbatim, un-deleted. + expect(seenCtx?.mcpReq.envelope).toEqual(ENVELOPE); + }); + + test('a partial envelope (a subset of the reserved keys) surfaces as received and types as Partial', async () => { + // A one-revision-old peer may legally send only some reserved keys + // (e.g. just the log-level opt-in). The lift surfaces whatever was + // present, and the ctx slot's type says so: every member is optional. + let seenCtx: BaseContext | undefined; + const { peer } = await wireReceiver(receiver => { + receiver.setRequestHandler('tools/call', (_request, ctx) => { + seenCtx = ctx; + return { content: [] }; + }); + }); + + await peer.send({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: { [LOG_LEVEL_META_KEY]: 'debug' } } + } as JSONRPCMessage); + await flush(); + + expect(seenCtx?.mcpReq.envelope).toEqual({ [LOG_LEVEL_META_KEY]: 'debug' }); + // The slot is Partial: a key the request did not + // carry reads as possibly-undefined — there is no claim that the + // required envelope members exist (requiredness is enforced per + // request at dispatch time, not by the lift). + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf(seenCtx!.mcpReq.envelope![PROTOCOL_VERSION_META_KEY]).toEqualTypeOf(); + expect(seenCtx?.mcpReq.envelope?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined(); + }); + + test('a _meta that holds only envelope keys disappears entirely (exact 2025 shape)', async () => { + let seenRequest: unknown; + const { peer } = await wireReceiver(receiver => { + receiver.setRequestHandler('tools/call', request => { + seenRequest = request; + return { content: [] }; + }); + }); + + await peer.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: { ...ENVELOPE } } + } as JSONRPCMessage); + await flush(); + + expect(seenRequest).toEqual({ + method: 'tools/call', + params: { name: 'echo', arguments: {} } + }); + }); + + test('retry fields are hidden from handler params and reach ctx un-deleted', async () => { + let seenRequest: unknown; + let seenCtx: BaseContext | undefined; + const { peer } = await wireReceiver(receiver => { + receiver.setRequestHandler('tools/call', (request, ctx) => { + seenRequest = request; + seenCtx = ctx; + return { content: [] }; + }); + }); + + const inputResponses = { 'req-1': { action: 'accept', content: { name: 'octocat' } } }; + await peer.send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'echo', arguments: {}, inputResponses, requestState: 'opaque-state-token' } + } as JSONRPCMessage); + await flush(); + + expect(seenRequest).toEqual({ + method: 'tools/call', + params: { name: 'echo', arguments: {} } + }); + expect(seenCtx?.mcpReq.inputResponses).toEqual(inputResponses); + expect(seenCtx?.mcpReq.requestState).toBe('opaque-state-token'); + }); + + test('the custom-method (3-arg) path also surfaces the envelope via ctx', async () => { + let seenParams: unknown; + let seenCtx: BaseContext | undefined; + const { peer, responses } = await wireReceiver(receiver => { + receiver.setRequestHandler('acme/search', { params: z.object({ query: z.string() }) }, (params, ctx) => { + seenParams = params; + seenCtx = ctx; + return { hits: [] }; + }); + }); + + await peer.send({ + jsonrpc: '2.0', + id: 4, + method: 'acme/search', + params: { query: 'mcp', _meta: { ...ENVELOPE } } + } as JSONRPCMessage); + await flush(); + + expect(seenParams).toEqual({ query: 'mcp' }); + expect(seenCtx?.mcpReq.envelope).toEqual(ENVELOPE); + expect(responses).toHaveLength(1); + }); + + test('the fallback request handler receives the lifted request too', async () => { + let seenRequest: JSONRPCRequest | undefined; + const { peer } = await wireReceiver(receiver => { + receiver.fallbackRequestHandler = request => { + seenRequest = request; + return Promise.resolve({} as Result); + }; + }); + + await peer.send({ + jsonrpc: '2.0', + id: 5, + method: 'vendor/anything', + params: { value: 1, _meta: { ...ENVELOPE }, requestState: 's' } + } as JSONRPCMessage); + await flush(); + + expect(seenRequest?.params).toEqual({ value: 1 }); + }); + + test('2025-era requests pass through untouched (same reference, no ctx slots)', async () => { + let seenRequest: JSONRPCRequest | undefined; + let seenCtx: BaseContext | undefined; + const { peer } = await wireReceiver(receiver => { + receiver.fallbackRequestHandler = (request, ctx) => { + seenRequest = request; + seenCtx = ctx; + return Promise.resolve({} as Result); + }; + }); + + const legacy = { + jsonrpc: '2.0', + id: 6, + method: 'vendor/legacy', + params: { value: 2, _meta: { progressToken: 9 } } + } as JSONRPCMessage; + await peer.send(legacy); + await flush(); + + // Identity preserved: the lift allocates nothing for clean traffic. + expect(seenRequest).toBe(legacy); + expect(seenCtx?.mcpReq.envelope).toBeUndefined(); + expect(seenCtx?.mcpReq.inputResponses).toBeUndefined(); + expect(seenCtx?.mcpReq.requestState).toBeUndefined(); + }); +}); + +describe('envelope lift on inbound notifications', () => { + test('notification handlers never see the reserved envelope keys', async () => { + let seenParams: unknown; + let seenNotification: unknown; + const { peer } = await wireReceiver(receiver => { + receiver.setNotificationHandler('vendor/changed', { params: z.object({ value: z.number() }) }, (params, notification) => { + seenParams = params; + seenNotification = notification; + }); + }); + + await peer.send({ + jsonrpc: '2.0', + method: 'vendor/changed', + params: { value: 42, _meta: { ...ENVELOPE, progressToken: 1 } } + } as JSONRPCMessage); + await flush(); + + expect(seenParams).toEqual({ value: 42 }); + // The raw notification handed to the handler is the lifted one: + // _meta retains only non-reserved material. + expect((seenNotification as { params?: { _meta?: unknown } }).params?._meta).toEqual({ progressToken: 1 }); + }); + + test('top-level params named like the retry fields reach notification handlers intact', async () => { + // The spec reserves `inputResponses`/`requestState` on + // client-initiated REQUESTS only. A vendor notification is free to + // use those names as ordinary params — the lift must not touch them + // (notifications have no ctx, so a delete would be unrecoverable). + let seenParams: unknown; + const { peer } = await wireReceiver(receiver => { + receiver.setNotificationHandler( + 'vendor/stateChanged', + { params: z.looseObject({ requestState: z.string() }) }, + params => void (seenParams = params) + ); + }); + + await peer.send({ + jsonrpc: '2.0', + method: 'vendor/stateChanged', + params: { requestState: 'app-domain-value', inputResponses: { poll: 'yes' }, _meta: { ...ENVELOPE } } + } as JSONRPCMessage); + await flush(); + + // Envelope keys lifted; the retry-named top-level params untouched. + expect(seenParams).toEqual({ requestState: 'app-domain-value', inputResponses: { poll: 'yes' } }); + }); + + test('the fallback notification handler receives the lifted notification', async () => { + let seen: unknown; + const { peer } = await wireReceiver(receiver => { + receiver.fallbackNotificationHandler = notification => { + seen = notification; + return Promise.resolve(); + }; + }); + + await peer.send({ + jsonrpc: '2.0', + method: 'vendor/ping', + params: { _meta: { ...ENVELOPE } } + } as JSONRPCMessage); + await flush(); + + expect((seen as { params?: unknown }).params).toEqual({}); + }); +}); diff --git a/packages/core/test/spec.types.2025-11-25.test.ts b/packages/core/test/spec.types.2025-11-25.test.ts index bf0903cd1a..45adde80e2 100644 --- a/packages/core/test/spec.types.2025-11-25.test.ts +++ b/packages/core/test/spec.types.2025-11-25.test.ts @@ -662,14 +662,6 @@ type AssertExactKeys< /** Constraint: T must resolve to `true`. */ type Assert = T; -/** - * Same as {@link AssertExactKeys}, but tolerates the SDK's `resultType` key on - * result shapes: the SDK follows the 2026-07-28 schema's optional `resultType` - * passthrough (absent means "complete"), which is not in released 2025-11-25. - * Every other key still has to match exactly. - */ -type AssertExactKeysWithResultType = AssertExactKeys; - /* * Excluded from key-level assertions (21 entries): * @@ -710,29 +702,27 @@ type _K_ElicitRequestURLParams = Assert>; type _K_BaseMetadata = Assert>; type _K_Implementation = Assert>; -type _K_PaginatedResult = Assert>; -type _K_ListRootsResult = Assert>; +type _K_PaginatedResult = Assert>; +type _K_ListRootsResult = Assert>; type _K_Root = Assert>; -type _K_ElicitResult = Assert>; -type _K_CompleteResult = Assert>; +type _K_ElicitResult = Assert>; +type _K_CompleteResult = Assert>; type _K_Request = Assert>; -type _K_Result = Assert>; +type _K_Result = Assert>; type _K_JSONRPCRequest = Assert>; type _K_JSONRPCNotification = Assert>; -type _K_EmptyResult = Assert>; +type _K_EmptyResult = Assert>; type _K_Notification = Assert>; type _K_ResourceTemplateReference = Assert>; // @ts-expect-error Genuine mismatch: SDK PromptReference is missing 'title' from spec type _K_PromptReference = Assert>; type _K_ToolAnnotations = Assert>; type _K_Tool = Assert>; -type _K_ListToolsResult = Assert>; -type _K_CallToolResult = Assert>; -type _K_ListResourcesResult = Assert>; -type _K_ListResourceTemplatesResult = Assert< - AssertExactKeysWithResultType ->; -type _K_ReadResourceResult = Assert>; +type _K_ListToolsResult = Assert>; +type _K_CallToolResult = Assert>; +type _K_ListResourcesResult = Assert>; +type _K_ListResourceTemplatesResult = Assert>; +type _K_ReadResourceResult = Assert>; type _K_ResourceContents = Assert>; type _K_TextResourceContents = Assert>; type _K_BlobResourceContents = Assert>; @@ -740,8 +730,8 @@ type _K_Resource = Assert // @ts-expect-error Genuine mismatch: SDK PromptArgument is missing 'title' from spec type _K_PromptArgument = Assert>; type _K_Prompt = Assert>; -type _K_ListPromptsResult = Assert>; -type _K_GetPromptResult = Assert>; +type _K_ListPromptsResult = Assert>; +type _K_GetPromptResult = Assert>; type _K_TextContent = Assert>; type _K_ImageContent = Assert>; type _K_AudioContent = Assert>; @@ -764,7 +754,7 @@ type _K_TitledMultiSelectEnumSchema = Assert>; type _K_JSONRPCErrorResponse = Assert>; type _K_JSONRPCResultResponse = Assert>; -type _K_InitializeResult = Assert>; +type _K_InitializeResult = Assert>; // @ts-expect-error SDK follows the 2026-07-28 schema's `extensions` capability key; not in released 2025-11-25 type _K_ClientCapabilities = Assert>; // @ts-expect-error SDK follows the 2026-07-28 schema's `extensions` capability key; not in released 2025-11-25 @@ -783,11 +773,11 @@ type _K_TaskMetadata = Assert>; type _K_TaskAugmentedRequestParams = Assert>; type _K_Task = Assert>; -type _K_CreateTaskResult = Assert>; -type _K_GetTaskResult = Assert>; -type _K_GetTaskPayloadResult = Assert>; -type _K_ListTasksResult = Assert>; -type _K_CancelTaskResult = Assert>; +type _K_CreateTaskResult = Assert>; +type _K_GetTaskResult = Assert>; +type _K_GetTaskPayloadResult = Assert>; +type _K_ListTasksResult = Assert>; +type _K_CancelTaskResult = Assert>; type _K_TaskStatusNotificationParams = Assert< AssertExactKeys >; @@ -855,7 +845,7 @@ type _K_CancelTaskRequest = Assert>; +type _K_CreateMessageResult = Assert>; type _K_ResourceTemplate = Assert>; // Types excluded from the key-parity completeness guard: union types and primitive aliases diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts index cb29e1e969..b7985ae2c8 100644 --- a/packages/core/test/types/errorSurfacePins.test.ts +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -74,6 +74,7 @@ describe('SdkErrorCode', () => { ConnectionClosed: 'CONNECTION_CLOSED', SendFailed: 'SEND_FAILED', InvalidResult: 'INVALID_RESULT', + UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', ClientHttpAuthentication: 'CLIENT_HTTP_AUTHENTICATION', ClientHttpForbidden: 'CLIENT_HTTP_FORBIDDEN', diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 198e104f9f..85e7d4c195 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -134,7 +134,13 @@ describe('SpecTypeName / SpecTypes (type-level)', () => { }); it('SpecTypes[K] matches the named export type', () => { - expectTypeOf().toEqualTypeOf(); + // Result entries are WIRE validator outputs: they carry the wire-only + // `resultType` member that the public result types deliberately do not + // declare. Stripping it must yield exactly the public type — pinned in + // both directions (the wire schema keeps modeling the member). + type StripWireOnly = { [K in keyof T as K extends 'resultType' ? never : K]: T[K] }; + expectTypeOf>().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/packages/core/test/types/wireOnlyHiding.test.ts b/packages/core/test/types/wireOnlyHiding.test.ts new file mode 100644 index 0000000000..0d89b8bbdb --- /dev/null +++ b/packages/core/test/types/wireOnlyHiding.test.ts @@ -0,0 +1,188 @@ +/** + * Public-face hiding pins: wire-only members and task vocabulary. + * + * Two contracts, enforced at the type level and against the committed API + * reports (which the api-report CI gate keeps in lockstep with the dts): + * + * 1. Wire-only members are absent from every public result type. `resultType` + * is the 2026-07-28 wire discrimination field; the SDK consumes it at the + * protocol layer and the public types do not declare it. The wire schemas + * keep modeling it internally (also pinned here, so the internal surface + * cannot drift silently either). + * + * 2. Task types are importable, deprecated wire vocabulary that appears in NO + * API signature: the typed method surface (RequestMethod/RequestTypeMap/ + * ResultTypeMap/NotificationTypeMap and everything built on them) offers + * no task method, and the only public declarations naming task types are + * the deprecated vocabulary cluster itself plus the exclusion helpers that + * subtract the task methods from the maps. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, expectTypeOf, test } from 'vitest'; +import type * as z from 'zod/v4'; + +import type { + CallToolResult, + CancelTaskResult, + CompleteResult, + CreateMessageResult, + CreateMessageResultWithTools, + CreateTaskResult, + ElicitResult, + EmptyResult, + GetTaskResult, + InitializeResult, + JSONRPCResultResponse, + ListRootsResult, + ListTasksResult, + ListToolsResult, + NotificationMethod, + ReadResourceResult, + RequestMethod, + RequestTypeMap, + Result, + ResultTypeMap, + Task, + TaskAugmentedRequestParams +} from '../../src/types/types.js'; +import { CallToolResultSchema, ResultSchema } from '../../src/types/schemas.js'; + +/** Declared (non-index-signature) keys of T. */ +type KnownKeyOf = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + +type DeclaresResultType = 'resultType' extends KnownKeyOf ? true : false; + +describe('wire-only members are hidden from the public result types', () => { + test('no public result type declares resultType', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + // Deprecated task results are public vocabulary and equally stripped. + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + // The response envelope embeds the public Result, not the wire shape. + expectTypeOf>().toEqualTypeOf(); + + // Value-assignability is untouched: handler-built results may still + // carry the member through the loose index signature (raw bytes can + // always carry it; the protocol layer owns it). + const handlerBuilt: CallToolResult = { content: [], resultType: 'complete' }; + expect(handlerBuilt).toBeDefined(); + }); + + test('the wire schemas keep modeling resultType internally', () => { + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + }); +}); + +describe('task vocabulary is importable but in no API signature', () => { + test('the typed method surface offers no task method', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('method-keyed results are plain (no unreachable task members)', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test('task types stay importable as wire vocabulary', () => { + // The type-only imports above are the proof; spot-check their shapes. + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'task' | '_meta'>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('every task type export is tagged @deprecated at the source', () => { + const source = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'types.ts'), 'utf8'); + const taskExports = [...source.matchAll(/export type (\w*Task\w*) /g)].map(match => match[1]); + expect(taskExports.length).toBeGreaterThanOrEqual(17); + for (const name of taskExports) { + const declaration = source.indexOf(`export type ${name} `); + const preceding = source.slice(Math.max(0, declaration - 400), declaration); + expect(preceding, `'${name}' must carry an @deprecated tag`).toContain('@deprecated'); + } + + const guards = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'guards.ts'), 'utf8'); + const guardDecl = guards.indexOf('export const isTaskAugmentedRequestParams'); + expect(guards.slice(Math.max(0, guardDecl - 500), guardDecl)).toContain('@deprecated'); + }); + + test('the task Zod schemas and the related-task meta key carry @deprecated too', () => { + // The migration docs claim the FULL task wire surface is deprecated — + // schemas and constants included, not just the inferred types. + const schemas = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'schemas.ts'), 'utf8'); + const schemaExports = [...schemas.matchAll(/export const (\w*Tasks?\w*Schema) /g)].map(match => match[1]); + expect(schemaExports.length).toBeGreaterThanOrEqual(19); + for (const name of schemaExports) { + const declaration = schemas.indexOf(`export const ${name} `); + const preceding = schemas.slice(Math.max(0, declaration - 400), declaration); + expect(preceding, `'${name}' must carry an @deprecated tag`).toContain('@deprecated'); + } + + // The `tasks` capability keys on both capability objects. + for (const member of ['tasks: ClientTasksCapabilitySchema.optional()', 'tasks: ServerTasksCapabilitySchema.optional()']) { + const declaration = schemas.indexOf(member); + expect(declaration, `capability member '${member}' must exist`).toBeGreaterThan(-1); + expect(schemas.slice(Math.max(0, declaration - 300), declaration), `'${member}' must carry an @deprecated tag`).toContain( + '@deprecated' + ); + } + + const constants = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'constants.ts'), 'utf8'); + const keyDecl = constants.indexOf('export const RELATED_TASK_META_KEY'); + expect(constants.slice(Math.max(0, keyDecl - 300), keyDecl)).toContain('@deprecated'); + }); +}); + +describe('API-report signature scan (no task type in any public signature)', () => { + const TASK_TYPE_NAME = + /\b(Task|TaskStatus|TaskCreationParams|TaskMetadata|RelatedTaskMetadata|CreateTaskResult|TaskStatusNotification|TaskStatusNotificationParams|GetTaskRequest|GetTaskResult|GetTaskPayloadRequest|GetTaskPayloadResult|ListTasksRequest|ListTasksResult|CancelTaskRequest|CancelTaskResult|TaskAugmentedRequestParams|TaskRequestMethod|TaskNotificationMethod)\b/; + + /** + * Declarations allowed to mention task type names: + * - the deprecated vocabulary cluster itself (Task* types, their schemas, + * the deprecated guard), and + * - the map declarations whose entire job is SUBTRACTING the task methods + * (the Exclude<> helpers and the four typed maps that use them). + */ + const EXEMPT_DECLARATION = new RegExp( + [ + '(export )?(type|const) (Task|CreateTask|GetTask|ListTasks|CancelTask|RelatedTaskMetadata|TaskAugmented)', + 'export const isTaskAugmentedRequestParams', + 'export type (RequestMethod|NotificationMethod|RequestTypeMap|NotificationTypeMap) =' + ].join('|') + ); + + const reports = ['../../../client/etc/client.api.md', '../../../server/etc/server.api.md']; + + test.each(reports)('%s', relPath => { + const report = readFileSync(join(__dirname, relPath), 'utf8'); + // Blocks are separated by blank lines in the API report format. + const blocks = report.split(/\n\s*\n/); + const offending: string[] = []; + for (const block of blocks) { + if (!TASK_TYPE_NAME.test(block)) continue; + if (block.includes('@deprecated')) continue; + if (EXEMPT_DECLARATION.test(block)) continue; + offending.push(block.trim().split('\n').slice(0, 3).join('\n')); + } + expect(offending, 'public declarations mentioning task types outside the deprecated vocabulary cluster').toEqual([]); + }); +}); diff --git a/packages/middleware/node/etc/node.api.md b/packages/middleware/node/etc/node.api.md index 4072252d77..7f71921290 100644 --- a/packages/middleware/node/etc/node.api.md +++ b/packages/middleware/node/etc/node.api.md @@ -38,10 +38,27 @@ type Flatten = T extends Primitive ? T : T extends Array ? Array = Flatten>; // @public (undocumented) -type JSONRPCMessage = Infer; +type JSONRPCErrorResponse = Infer; + +// @public +const JSONRPCErrorResponseSchema: z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; // @public (undocumented) -const JSONRPCMessageSchema: z.ZodUnion; + +// @public +const JSONRPCNotificationSchema: z.ZodObject<{ method: z.ZodString; params: z.ZodOptional>; }, z.core.$loose>>; jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>, z.ZodObject<{ +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCRequest = Infer; + +// @public +const JSONRPCRequestSchema: z.ZodObject<{ method: z.ZodString; params: z.ZodOptional>; }, z.core.$loose>>; jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>, z.ZodObject<{ + id: z.ZodUnion; +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCResultResponse = Omit, 'result'> & { + result: Result; +}; + +// @public +const JSONRPCResultResponseSchema: z.ZodObject<{ jsonrpc: z.ZodLiteral<"2.0">; id: z.ZodUnion; result: z.ZodObject<{ @@ -76,15 +107,7 @@ const JSONRPCMessageSchema: z.ZodUnion>; resultType: z.ZodOptional; }, z.core.$loose>; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>]>; +}, z.core.$strict>; // @public interface MessageExtraInfo { @@ -128,12 +151,29 @@ type RequestId = Infer; // @public const RequestIdSchema: z.ZodUnion; +// @public (undocumented) +type Result = StripWireOnly>; + +// @public (undocumented) +const ResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$loose>; + // @public (undocumented) type StreamId = string; // @public export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServerTransportOptions; +// @public +type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; + // @public interface Transport { close(): Promise; @@ -171,5 +211,8 @@ interface WebStandardStreamableHTTPServerTransportOptions { supportedProtocolVersions?: string[]; } +// @public +type WireOnlyResultKey = 'resultType'; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/server-legacy/etc/server-legacy.api.md b/packages/server-legacy/etc/server-legacy.api.md index 8b0a2b00bf..c41b516996 100644 --- a/packages/server-legacy/etc/server-legacy.api.md +++ b/packages/server-legacy/etc/server-legacy.api.md @@ -152,10 +152,27 @@ export class InvalidTokenError extends OAuthError { } // @public (undocumented) -type JSONRPCMessage = Infer; +type JSONRPCErrorResponse = Infer; + +// @public +const JSONRPCErrorResponseSchema: z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; // @public (undocumented) -const JSONRPCMessageSchema: z.ZodUnion; + +// @public +const JSONRPCNotificationSchema: z.ZodObject<{ method: z.ZodString; params: z.ZodOptional>; }, z.core.$loose>>; jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>, z.ZodObject<{ +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCRequest = Infer; + +// @public +const JSONRPCRequestSchema: z.ZodObject<{ method: z.ZodString; params: z.ZodOptional>; }, z.core.$loose>>; jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>, z.ZodObject<{ + id: z.ZodUnion; +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCResultResponse = Omit, 'result'> & { + result: Result; +}; + +// @public +const JSONRPCResultResponseSchema: z.ZodObject<{ jsonrpc: z.ZodLiteral<"2.0">; id: z.ZodUnion; result: z.ZodObject<{ @@ -190,15 +221,7 @@ const JSONRPCMessageSchema: z.ZodUnion>; resultType: z.ZodOptional; }, z.core.$loose>; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>]>; +}, z.core.$strict>; // @public interface MessageExtraInfo { @@ -430,6 +453,20 @@ type RequestId = Infer; // @public const RequestIdSchema: z.ZodUnion; +// @public (undocumented) +type Result = StripWireOnly>; + +// @public (undocumented) +const ResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$loose>; + // @public (undocumented) export type RevocationHandlerOptions = { provider: OAuthServerProvider; @@ -477,6 +514,9 @@ export class ServerError extends OAuthError { static errorCode: string; } +// @public +type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; + // @public export class TemporarilyUnavailableError extends OAuthError { // (undocumented) @@ -539,6 +579,9 @@ export class UnsupportedTokenTypeError extends OAuthError { static errorCode: string; } +// @public +type WireOnlyResultKey = 'resultType'; + // @public export function allowedMethods(allowedMethods: string[]): RequestHandler; diff --git a/packages/server-legacy/etc/server-legacy.sse.api.md b/packages/server-legacy/etc/server-legacy.sse.api.md index 4273264607..061f8796c8 100644 --- a/packages/server-legacy/etc/server-legacy.sse.api.md +++ b/packages/server-legacy/etc/server-legacy.sse.api.md @@ -25,10 +25,27 @@ type Flatten = T extends Primitive ? T : T extends Array ? Array = Flatten>; // @public (undocumented) -type JSONRPCMessage = Infer; +type JSONRPCErrorResponse = Infer; + +// @public +const JSONRPCErrorResponseSchema: z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; // @public (undocumented) -const JSONRPCMessageSchema: z.ZodUnion; + +// @public +const JSONRPCNotificationSchema: z.ZodObject<{ method: z.ZodString; params: z.ZodOptional>; }, z.core.$loose>>; jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>, z.ZodObject<{ +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCRequest = Infer; + +// @public +const JSONRPCRequestSchema: z.ZodObject<{ method: z.ZodString; params: z.ZodOptional>; }, z.core.$loose>>; jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>, z.ZodObject<{ + id: z.ZodUnion; +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCResultResponse = Omit, 'result'> & { + result: Result; +}; + +// @public +const JSONRPCResultResponseSchema: z.ZodObject<{ jsonrpc: z.ZodLiteral<"2.0">; id: z.ZodUnion; result: z.ZodObject<{ @@ -63,15 +94,7 @@ const JSONRPCMessageSchema: z.ZodUnion>; resultType: z.ZodOptional; }, z.core.$loose>; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>]>; +}, z.core.$strict>; // @public interface MessageExtraInfo { @@ -90,6 +113,20 @@ type RequestId = Infer; // @public const RequestIdSchema: z.ZodUnion; +// @public (undocumented) +type Result = StripWireOnly>; + +// @public (undocumented) +const ResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$loose>; + // @public @deprecated export class SSEServerTransport implements Transport { constructor(_endpoint: string, res: ServerResponse, options?: SSEServerTransportOptions); @@ -125,6 +162,9 @@ export interface SSEServerTransportOptions { enableDnsRebindingProtection?: boolean; } +// @public +type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; + // @public interface Transport { close(): Promise; @@ -145,5 +185,8 @@ type TransportSendOptions = { onresumptiontoken?: ((token: string) => void) | undefined; }; +// @public +type WireOnlyResultKey = 'resultType'; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/server/etc/server.api.md b/packages/server/etc/server.api.md index af06eddac9..8e76e14025 100644 --- a/packages/server/etc/server.api.md +++ b/packages/server/etc/server.api.md @@ -90,6 +90,9 @@ export type BaseContext = { id: RequestId; method: string; _meta?: RequestMeta; + envelope?: Partial; + inputResponses?: Record; + requestState?: string; signal: AbortSignal; send: { (request: { @@ -198,7 +201,7 @@ const CallToolRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type CallToolResult = Infer; +export type CallToolResult = StripWireOnly>; // @public const CallToolResultSchema: z.ZodObject<{ @@ -300,10 +303,10 @@ const CallToolResultSchema: z.ZodObject<{ isError: z.ZodOptional; }, z.core.$loose>; -// @public (undocumented) +// @public @deprecated (undocumented) export type CancelTaskRequest = Infer; -// @public +// @public @deprecated const CancelTaskRequestSchema: z.ZodObject<{ method: z.ZodLiteral<"tasks/cancel">; params: z.ZodObject<{ @@ -317,10 +320,10 @@ const CancelTaskRequestSchema: z.ZodObject<{ }, z.core.$strip>; }, z.core.$strip>; -// @public (undocumented) -export type CancelTaskResult = Infer; +// @public @deprecated (undocumented) +export type CancelTaskResult = StripWireOnly>; -// @public +// @public @deprecated const CancelTaskResultSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -766,7 +769,7 @@ const ClientRequestSchema: z.ZodUnion]>; // @public (undocumented) -export type ClientResult = Infer; +export type ClientResult = StripWireOnly>; // @public (undocumented) const ClientResultSchema: z.ZodUnion]>; // @public (undocumented) -export type CompatibilityCallToolResult = Infer; +export type CompatibilityCallToolResult = StripWireOnly>; // @public const CompatibilityCallToolResultSchema: z.ZodUnion<[z.ZodObject<{ @@ -1429,7 +1432,7 @@ export type CompleteResourceTemplateCallback = (value: string, context?: { }) => string[] | Promise; // @public (undocumented) -export type CompleteResult = Infer; +export type CompleteResult = StripWireOnly>; // @public const CompleteResultSchema: z.ZodObject<{ @@ -2276,7 +2279,7 @@ const CreateMessageRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type CreateMessageResult = Infer; +export type CreateMessageResult = StripWireOnly>; // @public const CreateMessageResultSchema: z.ZodObject<{ @@ -2339,7 +2342,7 @@ const CreateMessageResultSchema: z.ZodObject<{ }, z.core.$loose>; // @public (undocumented) -export type CreateMessageResultWithTools = Infer; +export type CreateMessageResultWithTools = StripWireOnly>; // @public const CreateMessageResultWithToolsSchema: z.ZodObject<{ @@ -2638,10 +2641,10 @@ const CreateMessageResultWithToolsSchema: z.ZodObject<{ }, z.core.$strip>], "type">>]>; }, z.core.$loose>; -// @public (undocumented) -export type CreateTaskResult = Infer; +// @public @deprecated (undocumented) +export type CreateTaskResult = StripWireOnly>; -// @public +// @public @deprecated const CreateTaskResultSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -2696,7 +2699,7 @@ const DiscoverRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type DiscoverResult = Infer; +export type DiscoverResult = StripWireOnly>; // @public const DiscoverResultSchema: z.ZodObject<{ @@ -3095,7 +3098,7 @@ const ElicitRequestURLParamsSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ElicitResult = Infer; +export type ElicitResult = StripWireOnly>; // @public const ElicitResultSchema: z.ZodObject<{ @@ -3174,7 +3177,7 @@ const EmbeddedResourceSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type EmptyResult = Infer; +export type EmptyResult = StripWireOnly>; // @public const EmptyResultSchema: z.ZodObject<{ @@ -3295,7 +3298,7 @@ const GetPromptRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type GetPromptResult = Infer; +export type GetPromptResult = StripWireOnly>; // @public const GetPromptResultSchema: z.ZodObject<{ @@ -3402,10 +3405,10 @@ const GetPromptResultSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$loose>; -// @public (undocumented) +// @public @deprecated (undocumented) export type GetTaskPayloadRequest = Infer; -// @public +// @public @deprecated const GetTaskPayloadRequestSchema: z.ZodObject<{ method: z.ZodLiteral<"tasks/result">; params: z.ZodObject<{ @@ -3419,10 +3422,10 @@ const GetTaskPayloadRequestSchema: z.ZodObject<{ }, z.core.$strip>; }, z.core.$strip>; -// @public (undocumented) -export type GetTaskPayloadResult = Infer; +// @public @deprecated (undocumented) +export type GetTaskPayloadResult = StripWireOnly>; -// @public +// @public @deprecated const GetTaskPayloadResultSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -3433,10 +3436,10 @@ const GetTaskPayloadResultSchema: z.ZodObject<{ resultType: z.ZodOptional; }, z.core.$loose>; -// @public (undocumented) +// @public @deprecated (undocumented) export type GetTaskRequest = Infer; -// @public +// @public @deprecated const GetTaskRequestSchema: z.ZodObject<{ method: z.ZodLiteral<"tasks/get">; params: z.ZodObject<{ @@ -3450,10 +3453,10 @@ const GetTaskRequestSchema: z.ZodObject<{ }, z.core.$strip>; }, z.core.$strip>; -// @public (undocumented) -export type GetTaskResult = Infer; +// @public @deprecated (undocumented) +export type GetTaskResult = StripWireOnly>; -// @public +// @public @deprecated const GetTaskResultSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -3731,7 +3734,7 @@ const InitializeRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type InitializeResult = Infer; +export type InitializeResult = StripWireOnly>; // @public const InitializeResultSchema: z.ZodObject<{ @@ -3851,53 +3854,7 @@ const JSONRPCErrorResponseSchema: z.ZodObject<{ }, z.core.$strict>; // @public (undocumented) -export type JSONRPCMessage = Infer; - -// @public (undocumented) -const JSONRPCMessageSchema: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>, z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>]>; +export type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; // @public (undocumented) export type JSONRPCNotification = Infer; @@ -3935,33 +3892,12 @@ const JSONRPCRequestSchema: z.ZodObject<{ }, z.core.$strict>; // @public (undocumented) -export type JSONRPCResponse = Infer; - -// @public (undocumented) -const JSONRPCResponseSchema: z.ZodUnion; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>]>; +export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; // @public (undocumented) -export type JSONRPCResultResponse = Infer; +export type JSONRPCResultResponse = Omit, 'result'> & { + result: Result; +}; // @public const JSONRPCResultResponseSchema: z.ZodObject<{ @@ -4061,7 +3997,7 @@ const ListPromptsRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ListPromptsResult = Infer; +export type ListPromptsResult = StripWireOnly>; // @public const ListPromptsResultSchema: z.ZodObject<{ @@ -4113,7 +4049,7 @@ const ListResourceTemplatesRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ListResourceTemplatesResult = Infer; +export type ListResourceTemplatesResult = StripWireOnly>; // @public const ListResourceTemplatesResultSchema: z.ZodObject<{ @@ -4173,7 +4109,7 @@ const ListResourcesRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ListResourcesResult = Infer; +export type ListResourcesResult = StripWireOnly>; // @public const ListResourcesResultSchema: z.ZodObject<{ @@ -4230,7 +4166,7 @@ const ListRootsRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ListRootsResult = Infer; +export type ListRootsResult = StripWireOnly>; // @public const ListRootsResultSchema: z.ZodObject<{ @@ -4248,10 +4184,10 @@ const ListRootsResultSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$loose>; -// @public (undocumented) +// @public @deprecated (undocumented) export type ListTasksRequest = Infer; -// @public +// @public @deprecated const ListTasksRequestSchema: z.ZodObject<{ params: z.ZodOptional; }, z.core.$strip>; -// @public (undocumented) -export type ListTasksResult = Infer; +// @public @deprecated (undocumented) +export type ListTasksResult = StripWireOnly>; -// @public +// @public @deprecated const ListTasksResultSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -4313,7 +4249,7 @@ const ListToolsRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ListToolsResult = Infer; +export type ListToolsResult = StripWireOnly>; // @public const ListToolsResultSchema: z.ZodObject<{ @@ -4557,7 +4493,7 @@ const MultiSelectEnumSchemaSchema: z.ZodUnion]>; // @public (undocumented) -export type NotificationMethod = ClientNotification['method'] | ServerNotification['method']; +export type NotificationMethod = Exclude; // @public type NotificationOptions_2 = { @@ -4582,7 +4518,9 @@ const NotificationSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type NotificationTypeMap = MethodToTypeMap; +export type NotificationTypeMap = MethodToTypeMap>; // @public (undocumented) type Notification_2 = Infer; @@ -4923,7 +4861,7 @@ const PaginatedRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type PaginatedResult = Infer; +export type PaginatedResult = StripWireOnly>; // @public (undocumented) const PaginatedResultSchema: z.ZodObject<{ @@ -5344,7 +5282,7 @@ export type ProtocolOptions = { // @public (undocumented) type ProtocolSchemaKey = (typeof SPEC_SCHEMA_KEYS)[number]; -// @public (undocumented) +// @public @deprecated export const RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; // @public @@ -5395,7 +5333,7 @@ const ReadResourceRequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type ReadResourceResult = Infer; +export type ReadResourceResult = StripWireOnly>; // @public const ReadResourceResultSchema: z.ZodObject<{ @@ -5512,10 +5450,10 @@ export type RegisteredTool = { remove(): void; }; -// @public (undocumented) +// @public @deprecated (undocumented) export type RelatedTaskMetadata = Infer; -// @public +// @public @deprecated const RelatedTaskMetadataSchema: z.ZodObject<{ taskId: z.ZodString; }, z.core.$strip>; @@ -5613,7 +5551,7 @@ const RequestMetaSchema: z.ZodObject<{ }, z.core.$loose>; // @public (undocumented) -export type RequestMethod = ClientRequest['method'] | ServerRequest['method']; +export type RequestMethod = Exclude; // @public export type RequestOptions = { @@ -5641,7 +5579,9 @@ const RequestSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type RequestTypeMap = MethodToTypeMap; +export type RequestTypeMap = MethodToTypeMap>; // @public (undocumented) type Request_2 = Infer; @@ -5837,7 +5777,7 @@ const ResourceUpdatedNotificationSchema: z.ZodObject<{ }, z.core.$strip>; // @public (undocumented) -export type Result = Infer; +export type Result = StripWireOnly>; // @public (undocumented) const ResultSchema: z.ZodObject<{ @@ -5863,15 +5803,11 @@ export type ResultTypeMap = { 'resources/read': ReadResourceResult; 'resources/subscribe': EmptyResult; 'resources/unsubscribe': EmptyResult; - 'tools/call': CallToolResult | CreateTaskResult; + 'tools/call': CallToolResult; 'tools/list': ListToolsResult; - 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools | CreateTaskResult; - 'elicitation/create': ElicitResult | CreateTaskResult; + 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools; + 'elicitation/create': ElicitResult; 'roots/list': ListRootsResult; - 'tasks/get': GetTaskResult; - 'tasks/result': Result; - 'tasks/list': ListTasksResult; - 'tasks/cancel': CancelTaskResult; }; // @public (undocumented) @@ -6432,6 +6368,7 @@ export enum SdkErrorCode { NotInitialized = "NOT_INITIALIZED", RequestTimeout = "REQUEST_TIMEOUT", SendFailed = "SEND_FAILED", + UnsupportedResultType = "UNSUPPORTED_RESULT_TYPE", } // @public @@ -6476,34 +6413,10 @@ export class Server extends Protocol { getClientVersion(): Implementation | undefined; getNegotiatedProtocolVersion(): string | undefined; // (undocumented) - listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise<{ - [x: string]: unknown; - roots: { - uri: string; - name?: string | undefined; - _meta?: Record | undefined; - }[]; - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - }>; + listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise; oninitialized?: () => void; // (undocumented) - ping(): Promise<{ - _meta?: { - [x: string]: unknown; - progressToken?: string | number | undefined; - "io.modelcontextprotocol/related-task"?: { - taskId: string; - } | undefined; - } | undefined; - resultType?: string | undefined; - }>; + ping(): Promise; registerCapabilities(capabilities: ServerCapabilities): void; sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string): Promise; // (undocumented) @@ -7235,7 +7148,7 @@ const ServerRequestSchema: z.ZodUnion]>; // @public (undocumented) -export type ServerResult = Infer; +export type ServerResult = StripWireOnly>; // @public (undocumented) const ServerResultSchema: z.ZodUnion = K extends `${infer N}Schema` ? N : never; +// @public +type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; + // @public (undocumented) export type SubscribeRequest = Infer; @@ -8027,13 +7943,13 @@ const SubscribeRequestSchema: z.ZodObject<{ }, z.core.$strip>; }, z.core.$strip>; -// @public (undocumented) +// @public @deprecated (undocumented) export type Task = Infer; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskAugmentedRequestParams = Infer; -// @public +// @public @deprecated const TaskAugmentedRequestParamsSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -8046,24 +7962,30 @@ const TaskAugmentedRequestParamsSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskCreationParams = Infer; -// @public +// @public @deprecated const TaskCreationParamsSchema: z.ZodObject<{ ttl: z.ZodOptional; pollInterval: z.ZodOptional; }, z.core.$loose>; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskMetadata = Infer; -// @public (undocumented) +// @public @deprecated (undocumented) const TaskMetadataSchema: z.ZodObject<{ ttl: z.ZodOptional; }, z.core.$strip>; +// @public (undocumented) +type TaskNotificationMethod = 'notifications/tasks/status'; + // @public +type TaskRequestMethod = 'tasks/get' | 'tasks/result' | 'tasks/list' | 'tasks/cancel'; + +// @public @deprecated const TaskSchema: z.ZodObject<{ taskId: z.ZodString; status: z.ZodEnum<{ @@ -8080,16 +8002,16 @@ const TaskSchema: z.ZodObject<{ statusMessage: z.ZodOptional; }, z.core.$strip>; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskStatus = Infer; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskStatusNotification = Infer; -// @public (undocumented) +// @public @deprecated (undocumented) export type TaskStatusNotificationParams = Infer; -// @public +// @public @deprecated const TaskStatusNotificationParamsSchema: z.ZodObject<{ _meta: z.ZodOptional>; @@ -8112,7 +8034,7 @@ const TaskStatusNotificationParamsSchema: z.ZodObject<{ statusMessage: z.ZodOptional; }, z.core.$strip>; -// @public +// @public @deprecated const TaskStatusNotificationSchema: z.ZodObject<{ method: z.ZodLiteral<"notifications/tasks/status">; params: z.ZodObject<{ @@ -8138,7 +8060,7 @@ const TaskStatusNotificationSchema: z.ZodObject<{ }, z.core.$strip>; }, z.core.$strip>; -// @public +// @public @deprecated const TaskStatusSchema: z.ZodEnum<{ working: "working"; input_required: "input_required"; @@ -8581,6 +8503,9 @@ export interface WebStandardStreamableHTTPServerTransportOptions { supportedProtocolVersions?: string[]; } +// @public +type WireOnlyResultKey = 'resultType'; + // @public type ZodRawShape = Record; @@ -8833,7 +8758,7 @@ export const isJSONRPCResultResponse: (value: unknown) => value is JSONRPCResult // @public export const isSpecType: GuardRecord; -// @public +// @public @deprecated export const isTaskAugmentedRequestParams: (value: unknown) => value is TaskAugmentedRequestParams; // @public diff --git a/packages/server/etc/server.stdio.api.md b/packages/server/etc/server.stdio.api.md index 8251cfabab..fd2b3bd380 100644 --- a/packages/server/etc/server.stdio.api.md +++ b/packages/server/etc/server.stdio.api.md @@ -25,10 +25,27 @@ type Flatten = T extends Primitive ? T : T extends Array ? Array = Flatten>; // @public (undocumented) -type JSONRPCMessage = Infer; +type JSONRPCErrorResponse = Infer; + +// @public +const JSONRPCErrorResponseSchema: z.ZodObject<{ + jsonrpc: z.ZodLiteral<"2.0">; + id: z.ZodOptional>; + error: z.ZodObject<{ + code: z.ZodNumber; + message: z.ZodString; + data: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; // @public (undocumented) -const JSONRPCMessageSchema: z.ZodUnion; + +// @public +const JSONRPCNotificationSchema: z.ZodObject<{ method: z.ZodString; params: z.ZodOptional>; }, z.core.$loose>>; jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>, z.ZodObject<{ +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCRequest = Infer; + +// @public +const JSONRPCRequestSchema: z.ZodObject<{ method: z.ZodString; params: z.ZodOptional>; }, z.core.$loose>>; jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>, z.ZodObject<{ + id: z.ZodUnion; +}, z.core.$strict>; + +// @public (undocumented) +type JSONRPCResultResponse = Omit, 'result'> & { + result: Result; +}; + +// @public +const JSONRPCResultResponseSchema: z.ZodObject<{ jsonrpc: z.ZodLiteral<"2.0">; id: z.ZodUnion; result: z.ZodObject<{ @@ -63,15 +94,7 @@ const JSONRPCMessageSchema: z.ZodUnion>; resultType: z.ZodOptional; }, z.core.$loose>; -}, z.core.$strict>, z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>]>; +}, z.core.$strict>; // @public interface MessageExtraInfo { @@ -90,6 +113,20 @@ type RequestId = Infer; // @public const RequestIdSchema: z.ZodUnion; +// @public (undocumented) +type Result = StripWireOnly>; + +// @public (undocumented) +const ResultSchema: z.ZodObject<{ + _meta: z.ZodOptional>; + "io.modelcontextprotocol/related-task": z.ZodOptional>; + }, z.core.$loose>>; + resultType: z.ZodOptional; +}, z.core.$loose>; + // @public export class StdioServerTransport implements Transport { constructor(_stdin?: Readable, _stdout?: Writable, options?: { @@ -114,6 +151,9 @@ export class StdioServerTransport implements Transport { start(): Promise; } +// @public +type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; + // @public interface Transport { close(): Promise; @@ -134,5 +174,8 @@ type TransportSendOptions = { onresumptiontoken?: ((token: string) => void) | undefined; }; +// @public +type WireOnlyResultKey = 'resultType'; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index f96d8ec1bc..4c8dc9c9b9 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -9,6 +9,7 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + EmptyResult, Implementation, InitializeRequest, InitializeResult, @@ -16,6 +17,7 @@ import type { JsonSchemaType, jsonSchemaValidator, ListRootsRequest, + ListRootsResult, LoggingLevel, LoggingMessageNotification, MessageExtraInfo, @@ -420,7 +422,7 @@ export class Server extends Protocol { return this._capabilities; } - async ping() { + async ping(): Promise { return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema); } @@ -612,7 +614,7 @@ export class Server extends Protocol { * Remains functional during the deprecation window (at least twelve months). * Migrate to passing paths via tool parameters, resource URIs, or configuration. */ - async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { + async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise { return this._requestWithSchema({ method: 'roots/list', params }, ListRootsResultSchema, options); } diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0edcfd3af0..0307681f43 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -129,5 +129,29 @@ describe('Server', () => { await server.close(); }); + + it('counter-offers only released versions when a draft revision is requested', async () => { + // ORDERING PIN — counter-offer leak guard. The initialize + // counter-offer is `supportedProtocolVersions[0]`: whatever sits at + // the head of that list is offered to EVERY legacy-era client whose + // requested version is unknown. Era-aware supported-version list + // semantics must therefore land BEFORE any LATEST/SUPPORTED + // constant bump that adds a 2026-era revision — bumping first + // would leak the modern revision into 2025-era initialize + // handshakes via this exact site. If this pin goes red because the + // constants moved, do NOT update it until the counter-offer is + // era-aware. + const DRAFT_REVISION = '2026-07-28'; + expect(SUPPORTED_PROTOCOL_VERSIONS).not.toContain(DRAFT_REVISION); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + + const respondedVersion = await initializeServer(server, DRAFT_REVISION); + + expect(respondedVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(respondedVersion).not.toBe(DRAFT_REVISION); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await server.close(); + }); }); }); diff --git a/scripts/fetch-spec-examples.ts b/scripts/fetch-spec-examples.ts index 2c53f77df6..d20b73eb62 100644 --- a/scripts/fetch-spec-examples.ts +++ b/scripts/fetch-spec-examples.ts @@ -23,7 +23,7 @@ import { execFileSync } from 'node:child_process'; import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); @@ -96,12 +96,21 @@ function writeCorpus(files: ExampleFile[], sha: string): void { const dirs: Record = {}; for (const file of files.sort((a, b) => a.relPath.localeCompare(b.relPath))) { - const [typeDir, fileName] = file.relPath.split('/'); - if (!typeDir || !fileName) throw new Error(`Unexpected example path: ${file.relPath}`); + // The path components come from outside this repo (a spec checkout or the + // GitHub trees API); reject anything that could escape the output directory. + const parts = file.relPath.split('/'); + if (parts.length !== 2 || parts.some(p => !p || p === '.' || p === '..' || p.includes('\\'))) { + throw new Error(`Unsafe or unexpected example path: ${file.relPath}`); + } + const [typeDir, fileName] = parts as [string, string]; + const destFile = resolve(OUTPUT_DIR, typeDir, fileName); + if (!destFile.startsWith(resolve(OUTPUT_DIR) + sep)) { + throw new Error(`Example path escapes the output directory: ${file.relPath}`); + } mkdirSync(join(OUTPUT_DIR, typeDir), { recursive: true }); // Validate now so a malformed upstream example fails the vendoring, not the harness. JSON.parse(file.content); - writeFileSync(join(OUTPUT_DIR, typeDir, fileName), file.content); + writeFileSync(destFile, file.content); (dirs[typeDir] ??= []).push(fileName); } diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index ea471a21fc..750b8e537b 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -154,6 +154,13 @@ export const REQUIREMENTS: Record = { behavior: 'The receiver silently ignores a cancellation notification referencing an unknown or already-completed request id; no error response is sent and no exception is raised.' }, + 'typescript:client:raw-result-type-first': { + source: 'sdk', + behavior: + 'A raw input_required result body through the full client path surfaces the discriminated kind as a typed local error (UNSUPPORTED_RESULT_TYPE with data.resultType) — never an empty-content success, on any spec-version axis.', + transports: ['inMemory', 'streamableHttp'], + note: 'The client funnel inspects the raw resultType before schema validation, closing the masking hazard where the tools/call result schema would default content to [] and report a hollow success. Raw relay servers stand in for a 2026-era peer; the streamableHttp leg uses a hand handler (custom fetch), so the cells exercise both an in-process and an HTTP response path.' + }, 'typescript:protocol:error:connection-closed': { source: 'sdk', behavior: 'Closing the transport invokes onclose and rejects all in-flight requests with ErrorCode.ConnectionClosed.', diff --git a/test/e2e/scenarios/raw-result-type.test.ts b/test/e2e/scenarios/raw-result-type.test.ts new file mode 100644 index 0000000000..463bfccddf --- /dev/null +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -0,0 +1,92 @@ +/** + * Raw-first result discrimination through the full client path. + * + * A raw relay server (no SDK Server involved) answers tools/call with an + * `input_required` body — the 2026-era multi-round-trip shape. The full + * client stack (Client → protocol funnel → transport) must surface the + * discriminated kind as a typed local error and never mask it into an + * empty-content success (the tools/call result schema defaults `content` to + * `[]`, which would otherwise swallow the body whole). + */ +import { Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { JSONRPCRequest } from '@modelcontextprotocol/server'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const INPUT_REQUIRED_BODY = { + resultType: 'input_required', + inputRequests: { + 'elicit-1': { + method: 'elicitation/create', + params: { mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', properties: {} } } + } + }, + requestState: 'opaque-state' +}; + +function initializeResult(requestedVersion: string) { + return { + protocolVersion: requestedVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'raw-input-required-server', version: '0' } + }; +} + +/** Route a raw request to the relay's hand-built response body. */ +function respondTo(request: JSONRPCRequest): unknown { + if (request.method === 'initialize') { + const requested = (request.params as { protocolVersion?: string } | undefined)?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + return initializeResult(requested); + } + if (request.method === 'tools/call') return INPUT_REQUIRED_BODY; + return {}; +} + +async function connectInMemory(client: Client): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + if (request.id === undefined) return; // notifications need no answer + void serverTx.send({ jsonrpc: '2.0', id: request.id, result: respondTo(request) } as Parameters[0]); + }; + await serverTx.start(); + await client.connect(clientTx); +} + +async function connectStreamableHttp(client: Client): Promise { + // A hand HTTP handler (no SDK server): JSON responses, 202 for notifications. + const fetchHandler = async (input: URL | string, init?: RequestInit): Promise => { + const request = new Request(input, init); + if (request.method !== 'POST') return new Response(null, { status: 405 }); + const body = (await request.json()) as JSONRPCRequest | JSONRPCRequest[]; + const message = Array.isArray(body) ? body[0] : body; + if (message?.id === undefined) return new Response(null, { status: 202 }); + return Response.json({ jsonrpc: '2.0', id: message.id, result: respondTo(message) }); + }; + await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: fetchHandler })); +} + +verifies('typescript:client:raw-result-type-first', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'raw-result-type-client', version: '0' }); + await (transport === 'inMemory' ? connectInMemory(client) : connectStreamableHttp(client)); + + try { + const outcome = await client.callTool({ name: 'anything', arguments: {} }).then( + result => ({ resolved: result as unknown }), + error => ({ rejected: error as unknown }) + ); + + // Never an empty-content success. + expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + const typed = rejection as SdkError; + expect(typed.code).toBe(SdkErrorCode.UnsupportedResultType); + expect(typed.data).toMatchObject({ resultType: 'input_required', method: 'tools/call' }); + } finally { + await client.close(); + } +}); From 2b925562db3ace2cd0db31201b7c6c43b3da655e Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:28:38 +0100 Subject: [PATCH 09/37] build: remove the committed API reports and their CI gate (#2299) --- .gitattributes | 4 - .github/workflows/main.yml | 25 - .gitignore | 3 - .prettierignore | 4 - CONTRIBUTING.md | 10 - package.json | 3 - packages/client/etc/client.api.md | 9021 ----------------- .../client/etc/client.shims-browser.api.md | 47 - .../client/etc/client.shims-workerd.api.md | 47 - packages/client/etc/client.shims.api.md | 60 - packages/client/etc/client.stdio.api.md | 191 - .../client/etc/client.validators-ajv.api.md | 1572 --- .../etc/client.validators-cf-worker.api.md | 44 - .../core/test/types/wireOnlyHiding.test.ts | 41 +- .../middleware/express/etc/express.api.md | 95 - .../middleware/fastify/etc/fastify.api.md | 27 - packages/middleware/hono/etc/hono.api.md | 26 - packages/middleware/node/etc/node.api.md | 218 - .../server-legacy/etc/server-legacy.api.md | 631 -- .../etc/server-legacy.auth.api.md | 459 - .../etc/server-legacy.sse.api.md | 192 - packages/server/etc/server.api.md | 8793 ---------------- .../server/etc/server.shims-workerd.api.md | 51 - packages/server/etc/server.shims.api.md | 60 - packages/server/etc/server.stdio.api.md | 181 - .../server/etc/server.validators-ajv.api.md | 1572 --- .../etc/server.validators-cf-worker.api.md | 44 - pnpm-lock.yaml | 207 - scripts/generate-api-reports.ts | 683 -- 29 files changed, 4 insertions(+), 24307 deletions(-) delete mode 100644 .gitattributes delete mode 100644 packages/client/etc/client.api.md delete mode 100644 packages/client/etc/client.shims-browser.api.md delete mode 100644 packages/client/etc/client.shims-workerd.api.md delete mode 100644 packages/client/etc/client.shims.api.md delete mode 100644 packages/client/etc/client.stdio.api.md delete mode 100644 packages/client/etc/client.validators-ajv.api.md delete mode 100644 packages/client/etc/client.validators-cf-worker.api.md delete mode 100644 packages/middleware/express/etc/express.api.md delete mode 100644 packages/middleware/fastify/etc/fastify.api.md delete mode 100644 packages/middleware/hono/etc/hono.api.md delete mode 100644 packages/middleware/node/etc/node.api.md delete mode 100644 packages/server-legacy/etc/server-legacy.api.md delete mode 100644 packages/server-legacy/etc/server-legacy.auth.api.md delete mode 100644 packages/server-legacy/etc/server-legacy.sse.api.md delete mode 100644 packages/server/etc/server.api.md delete mode 100644 packages/server/etc/server.shims-workerd.api.md delete mode 100644 packages/server/etc/server.shims.api.md delete mode 100644 packages/server/etc/server.stdio.api.md delete mode 100644 packages/server/etc/server.validators-ajv.api.md delete mode 100644 packages/server/etc/server.validators-cf-worker.api.md delete mode 100644 scripts/generate-api-reports.ts diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 7334617a74..0000000000 --- a/.gitattributes +++ /dev/null @@ -1,4 +0,0 @@ -# Generated API report baselines: regenerate with `pnpm api-report`, review -# diffs like source, never hand-edit. LF is pinned because the api-report CI -# gate compares them byte-exactly against freshly generated (LF) output. -packages/**/etc/*.api.md linguist-generated=true text eol=lf diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f55006abaa..5686454414 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -58,31 +58,6 @@ jobs: # The e2e suite has its own job below; everything else runs here. - run: pnpm -r --filter '!@modelcontextprotocol/test-e2e' test - api-report: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - name: Install pnpm - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - id: pnpm-install - with: - run_install: false - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - run: pnpm install - - # Fails when the built public type surface of any package differs - # from its committed API report (packages/*/etc/*.api.md). To accept - # an intentional surface change: run `pnpm api-report`, review the - # report diff, and commit it. See the header of scripts/generate-api-reports.ts. - - run: pnpm run api-report:check - test-e2e: runs-on: ubuntu-latest strategy: diff --git a/.gitignore b/.gitignore index 898d0265ff..6372eb1d57 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,3 @@ results/ # Ignore local lefthook configuration lefthook-local.yml - -# API report scratch folders (committed reports live in packages/*/etc/) -.api-extractor-tmp/ diff --git a/.prettierignore b/.prettierignore index d3011b6d9a..845eaaa988 100644 --- a/.prettierignore +++ b/.prettierignore @@ -24,7 +24,3 @@ packages/codemod/batch-test/results # Quickstart examples uses 2-space indent to match ecosystem conventions examples/client-quickstart/ examples/server-quickstart/ - -# Generated API reports (machine-formatted by API Extractor) -packages/**/etc/*.api.md -.api-extractor-tmp diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25b29efe09..325330c15b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,16 +110,6 @@ Then: 4. Run `pnpm test:all` to verify all tests pass 5. Submit a pull request -### API Reports - -Every public package's type surface is pinned by a committed API report (`packages//etc/.api.md`), and CI fails when the built surface differs from the committed baseline. A failing `api-report` check does not mean your change is wrong — it means it is surface-visible. - -- If the surface change is intentional: run `pnpm api-report` (~30s: builds the packages and regenerates the reports), review the report diff like source, and commit it together with your change — plus a changeset if it is consumer-facing. -- Never hand-edit a report; the check compares byte-exactly against regenerated output. -- Resolve merge conflicts in `.api.md` files by taking either side and rerunning `pnpm api-report`, not by hand-merging. - -See the header of `scripts/generate-api-reports.ts` for how the reports are produced and which packages are covered. - ### Running Examples See [`examples/server/README.md`](examples/server/README.md) and [`examples/client/README.md`](examples/client/README.md) for a full list of runnable examples. diff --git a/package.json b/package.json index 0aa41cd91f..848b5ab273 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,6 @@ "lint:fix:all": "pnpm sync:snippets && pnpm -r lint:fix", "check:all": "pnpm -r typecheck && pnpm -r lint && pnpm run docs:check", "test:all": "pnpm -r test", - "api-report": "pnpm --filter \"./packages/**\" build && tsx scripts/generate-api-reports.ts", - "api-report:check": "pnpm --filter \"./packages/**\" build && tsx scripts/generate-api-reports.ts --check", "prepare": "npx --no-install lefthook install", "test:conformance:client": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client", "test:conformance:client:all": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:all", @@ -53,7 +51,6 @@ "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", "@eslint/js": "catalog:devTools", - "@microsoft/api-extractor": "7.58.7", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/node": "workspace:^", "@modelcontextprotocol/server": "workspace:^", diff --git a/packages/client/etc/client.api.md b/packages/client/etc/client.api.md deleted file mode 100644 index 7e9108dfd2..0000000000 --- a/packages/client/etc/client.api.md +++ /dev/null @@ -1,9021 +0,0 @@ -## API Report File for "@modelcontextprotocol/client" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { ErrorEvent as ErrorEvent_2 } from 'eventsource'; -import { EventSourceInit as EventSourceInit_2 } from 'eventsource'; -import { JSONSchema } from 'json-schema-typed'; -import * as z from 'zod/v4'; - -// @public -export type AddClientAuthentication = (headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata) => void | Promise; - -// @public -export class AjvJsonSchemaValidator implements jsonSchemaValidator { - constructor(ajv?: AjvLike); - // (undocumented) - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -interface AjvLike { - // (undocumented) - compile: (schema: unknown) => AjvValidateFunction; - // (undocumented) - errorsText: (errors?: any) => string; - // (undocumented) - getSchema: (keyRef: string) => AjvValidateFunction | undefined; -} - -// @public (undocumented) -interface AjvValidateFunction { - // (undocumented) - (input: unknown): boolean; - // (undocumented) - errors?: any; -} - -// @public (undocumented) -export type Annotations = Infer; - -// @public -const AnnotationsSchema: z.ZodObject<{ - audience: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; -}, z.core.$strip>; - -// @public -export type AssertionCallback = (context: CrossAppAccessContext) => string | Promise; - -// @public (undocumented) -export type AudioContent = Infer; - -// @public -const AudioContentSchema: z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public -export interface AuthInfo { - clientId: string; - expiresAt?: number; - extra?: Record; - resource?: URL; - scopes: string[]; - token: string; -} - -// @public -export interface AuthProvider { - onUnauthorized?(ctx: UnauthorizedContext): Promise; - token(): Promise; -} - -// @public (undocumented) -export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; - -// @public (undocumented) -type AuthSchemaKey = keyof typeof authSchemas; - -// @public (undocumented) -export type AuthorizationServerMetadata = OAuthMetadata | OpenIdProviderDiscoveryMetadata; - -// @public -export type BaseContext = { - sessionId?: string; - mcpReq: { - id: RequestId; - method: string; - _meta?: RequestMeta; - envelope?: Partial; - inputResponses?: Record; - requestState?: string; - signal: AbortSignal; - send: { - (request: { - method: M; - params?: Record; - }, options?: RequestOptions): Promise; - (request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; - }; - notify: (notification: Notification_2) => Promise; - }; - http?: { - authInfo?: AuthInfo; - }; -}; - -// @public (undocumented) -export type BaseMetadata = Infer; - -// @public -const BaseMetadataSchema: z.ZodObject<{ - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public -const BaseRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; -}, z.core.$strip>; - -// @public (undocumented) -export type BlobResourceContents = Infer; - -// @public (undocumented) -const BlobResourceContentsSchema: z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; -}, z.core.$strip>; - -// @public (undocumented) -export type BooleanSchema = Infer; - -// @public -const BooleanSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public -export const CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities"; - -// @public -export const CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo"; - -// @public (undocumented) -export type CallToolRequest = Infer; - -// @public (undocumented) -export type CallToolRequestParams = Infer; - -// @public -const CallToolRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - name: z.ZodString; - arguments: z.ZodOptional>; -}, z.core.$strip>; - -// @public -const CallToolRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"tools/call">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - name: z.ZodString; - arguments: z.ZodOptional>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type CallToolResult = StripWireOnly>; - -// @public -const CallToolResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type CancelTaskRequest = Infer; - -// @public @deprecated -const CancelTaskRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"tasks/cancel">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type CancelTaskResult = StripWireOnly>; - -// @public @deprecated -const CancelTaskResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type CancelledNotification = Infer; - -// @public (undocumented) -export type CancelledNotificationParams = Infer; - -// @public (undocumented) -const CancelledNotificationParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - requestId: z.ZodOptional>; - reason: z.ZodOptional; -}, z.core.$strip>; - -// @public -const CancelledNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/cancelled">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - requestId: z.ZodOptional>; - reason: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public -export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { - constructor(options?: { - shortcircuit?: boolean; - draft?: CfWorkerSchemaDraft; - }); - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; - -// @public -export class Client extends Protocol { - constructor(_clientInfo: Implementation, options?: ClientOptions); - // (undocumented) - protected assertCapability(capability: keyof ServerCapabilities, method: string): void; - // (undocumented) - protected assertCapabilityForMethod(method: RequestMethod | string): void; - // (undocumented) - protected assertNotificationCapability(method: NotificationMethod | string): void; - // (undocumented) - protected assertRequestHandlerCapability(method: string): void; - // (undocumented) - protected buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext; - callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise; - complete(params: CompleteRequest['params'], options?: RequestOptions): Promise; - connect(transport: Transport, options?: RequestOptions): Promise; - getInstructions(): string | undefined; - getNegotiatedProtocolVersion(): string | undefined; - getPrompt(params: GetPromptRequest['params'], options?: RequestOptions): Promise; - getServerCapabilities(): ServerCapabilities | undefined; - getServerVersion(): Implementation | undefined; - listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions): Promise; - listResources(params?: ListResourcesRequest['params'], options?: RequestOptions): Promise; - listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions): Promise; - listTools(params?: ListToolsRequest['params'], options?: RequestOptions): Promise; - // (undocumented) - ping(options?: RequestOptions): Promise; - readResource(params: ReadResourceRequest['params'], options?: RequestOptions): Promise; - registerCapabilities(capabilities: ClientCapabilities): void; - sendRootsListChanged(): Promise; - setLoggingLevel(level: LoggingLevel, options?: RequestOptions): Promise; - subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions): Promise; - unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions): Promise; - protected _wrapHandler(method: string, handler: (request: JSONRPCRequest, ctx: ClientContext) => Promise): (request: JSONRPCRequest, ctx: ClientContext) => Promise; -} - -// @public (undocumented) -export type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; - -// @public (undocumented) -export type ClientCapabilities = Infer; - -// @public -const ClientCapabilitiesSchema: z.ZodObject<{ - experimental: z.ZodOptional>>>; - sampling: z.ZodOptional>>; - tools: z.ZodOptional>>; - }, z.core.$strip>>; - elicitation: z.ZodOptional, z.ZodIntersection; - }, z.core.$strip>, z.ZodType>>>; - url: z.ZodOptional>>; - }, z.core.$strip>, z.ZodOptional>>>>>; - roots: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - elicitation: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; -}, z.core.$strip>; - -// @public -export type ClientContext = BaseContext; - -// @public -export class ClientCredentialsProvider implements OAuthClientProvider { - constructor(options: ClientCredentialsProviderOptions); - // (undocumented) - clientInformation(): OAuthClientInformation; - // (undocumented) - get clientMetadata(): OAuthClientMetadata; - // (undocumented) - codeVerifier(): string; - // (undocumented) - prepareTokenRequest(scope?: string): URLSearchParams; - // (undocumented) - redirectToAuthorization(): void; - // (undocumented) - get redirectUrl(): undefined; - // (undocumented) - saveClientInformation(info: OAuthClientInformation): void; - // (undocumented) - saveCodeVerifier(): void; - // (undocumented) - saveTokens(tokens: OAuthTokens): void; - // (undocumented) - tokens(): OAuthTokens | undefined; -} - -// @public -export interface ClientCredentialsProviderOptions { - clientId: string; - clientName?: string; - clientSecret: string; - scope?: string; -} - -// @public (undocumented) -export type ClientNotification = Infer; - -// @public (undocumented) -const ClientNotificationSchema: z.ZodUnion; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - requestId: z.ZodOptional>; - reason: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/progress">; - params: z.ZodObject<{ - progressToken: z.ZodUnion; - progress: z.ZodNumber; - total: z.ZodOptional; - message: z.ZodOptional; - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/initialized">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/roots/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/tasks/status">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type ClientOptions = ProtocolOptions & { - capabilities?: ClientCapabilities; - jsonSchemaValidator?: jsonSchemaValidator; - listChanged?: ListChangedHandlers; -}; - -// @public (undocumented) -export type ClientRequest = Infer; - -// @public (undocumented) -const ClientRequestSchema: z.ZodUnion; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"initialize">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - protocolVersion: z.ZodString; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - sampling: z.ZodOptional>>; - tools: z.ZodOptional>>; - }, z.core.$strip>>; - elicitation: z.ZodOptional, z.ZodIntersection; - }, z.core.$strip>, z.ZodType>>>; - url: z.ZodOptional>>; - }, z.core.$strip>, z.ZodOptional>>>>>; - roots: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - elicitation: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - clientInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"completion/complete">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - ref: z.ZodUnion; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"ref/resource">; - uri: z.ZodString; - }, z.core.$strip>]>; - argument: z.ZodObject<{ - name: z.ZodString; - value: z.ZodString; - }, z.core.$strip>; - context: z.ZodOptional>; - }, z.core.$strip>>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"logging/setLevel">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"prompts/get">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - name: z.ZodString; - arguments: z.ZodOptional>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"prompts/list">; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"resources/list">; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"resources/templates/list">; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"resources/read">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"resources/subscribe">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"resources/unsubscribe">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tools/call">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - name: z.ZodString; - arguments: z.ZodOptional>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"tools/list">; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/get">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/result">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"tasks/list">; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/cancel">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type ClientResult = StripWireOnly>; - -// @public (undocumented) -const ClientResultSchema: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$strict>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - model: z.ZodString; - stopReason: z.ZodOptional, z.ZodString]>>; - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodDiscriminatedUnion<[z.ZodObject<{ - type: z.ZodLiteral<"text">; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - model: z.ZodString; - stopReason: z.ZodOptional, z.ZodString]>>; - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - action: z.ZodEnum<{ - cancel: "cancel"; - accept: "accept"; - decline: "decline"; - }>; - content: z.ZodPipe, z.ZodOptional]>>>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - roots: z.ZodArray; - _meta: z.ZodOptional>; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - tasks: z.ZodArray; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - task: z.ZodObject<{ - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$loose>]>; - -// @public (undocumented) -export type CompatibilityCallToolResult = StripWireOnly>; - -// @public -const CompatibilityCallToolResultSchema: z.ZodUnion<[z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - toolResult: z.ZodUnknown; -}, z.core.$loose>]>; - -// @public (undocumented) -export type CompleteRequest = Infer; - -// @public (undocumented) -export type CompleteRequestParams = Infer; - -// @public -const CompleteRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - ref: z.ZodUnion; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"ref/resource">; - uri: z.ZodString; - }, z.core.$strip>]>; - argument: z.ZodObject<{ - name: z.ZodString; - value: z.ZodString; - }, z.core.$strip>; - context: z.ZodOptional>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type CompleteRequestPrompt = ExpandRecursively; - -// @public (undocumented) -export type CompleteRequestResourceTemplate = ExpandRecursively; - -// @public -const CompleteRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"completion/complete">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - ref: z.ZodUnion; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"ref/resource">; - uri: z.ZodString; - }, z.core.$strip>]>; - argument: z.ZodObject<{ - name: z.ZodString; - value: z.ZodString; - }, z.core.$strip>; - context: z.ZodOptional>; - }, z.core.$strip>>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type CompleteResult = StripWireOnly>; - -// @public -const CompleteResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - completion: z.ZodObject<{ - values: z.ZodArray; - total: z.ZodOptional; - hasMore: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$loose>; - -// @public (undocumented) -export type ContentBlock = Infer; - -// @public -const ContentBlockSchema: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type CreateMessageRequest = Infer; - -// @public (undocumented) -export type CreateMessageRequestParams = Infer; - -// @public -export type CreateMessageRequestParamsBase = Omit; - -// @public -const CreateMessageRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - messages: z.ZodArray; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; - _meta: z.ZodOptional>; - }, z.core.$strip>>; - modelPreferences: z.ZodOptional; - }, z.core.$strip>>>; - costPriority: z.ZodOptional; - speedPriority: z.ZodOptional; - intelligencePriority: z.ZodOptional; - }, z.core.$strip>>; - systemPrompt: z.ZodOptional; - includeContext: z.ZodOptional>; - temperature: z.ZodOptional; - maxTokens: z.ZodNumber; - stopSequences: z.ZodOptional>; - metadata: z.ZodOptional>>; - tools: z.ZodOptional; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>>; - toolChoice: z.ZodOptional>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public -export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams { - // (undocumented) - tools: Tool[]; -} - -// @public -const CreateMessageRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"sampling/createMessage">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - messages: z.ZodArray; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; - _meta: z.ZodOptional>; - }, z.core.$strip>>; - modelPreferences: z.ZodOptional; - }, z.core.$strip>>>; - costPriority: z.ZodOptional; - speedPriority: z.ZodOptional; - intelligencePriority: z.ZodOptional; - }, z.core.$strip>>; - systemPrompt: z.ZodOptional; - includeContext: z.ZodOptional>; - temperature: z.ZodOptional; - maxTokens: z.ZodNumber; - stopSequences: z.ZodOptional>; - metadata: z.ZodOptional>>; - tools: z.ZodOptional; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>>; - toolChoice: z.ZodOptional>; - }, z.core.$strip>>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type CreateMessageResult = StripWireOnly>; - -// @public -const CreateMessageResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - model: z.ZodString; - stopReason: z.ZodOptional, z.ZodString]>>; - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodDiscriminatedUnion<[z.ZodObject<{ - type: z.ZodLiteral<"text">; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">; -}, z.core.$loose>; - -// @public (undocumented) -export type CreateMessageResultWithTools = StripWireOnly>; - -// @public -const CreateMessageResultWithToolsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - model: z.ZodString; - stopReason: z.ZodOptional, z.ZodString]>>; - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type CreateTaskResult = StripWireOnly>; - -// @public @deprecated -const CreateTaskResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - task: z.ZodObject<{ - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$loose>; - -// @public -export interface CrossAppAccessContext { - authorizationServerUrl: string; - fetchFn: FetchLike; - resourceUrl: string; - scope?: string; -} - -// @public -export class CrossAppAccessProvider implements OAuthClientProvider { - constructor(options: CrossAppAccessProviderOptions); - authorizationServerUrl?(): string | undefined; - // (undocumented) - clientInformation(): OAuthClientInformation; - // (undocumented) - get clientMetadata(): OAuthClientMetadata; - // (undocumented) - codeVerifier(): string; - // (undocumented) - prepareTokenRequest(scope?: string): Promise; - // (undocumented) - redirectToAuthorization(): void; - // (undocumented) - get redirectUrl(): undefined; - resourceUrl?(): string | undefined; - saveAuthorizationServerUrl?(authorizationServerUrl: string): void; - // (undocumented) - saveClientInformation(info: OAuthClientInformation): void; - // (undocumented) - saveCodeVerifier(): void; - saveResourceUrl?(resourceUrl: string): void; - // (undocumented) - saveTokens(tokens: OAuthTokens): void; - // (undocumented) - tokens(): OAuthTokens | undefined; -} - -// @public -export interface CrossAppAccessProviderOptions { - assertion: AssertionCallback; - clientId: string; - clientName?: string; - clientSecret: string; - fetchFn?: FetchLike; -} - -// @public (undocumented) -export type Cursor = Infer; - -// @public -const CursorSchema: z.ZodString; - -// @public (undocumented) -export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; - -// @public -export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60000; - -// @public -export interface DiscoverAndRequestJwtAuthGrantOptions extends Omit { - idpUrl: string | URL; -} - -// @public (undocumented) -export type DiscoverRequest = Infer; - -// @public -const DiscoverRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"server/discover">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type DiscoverResult = StripWireOnly>; - -// @public -const DiscoverResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - supportedVersions: z.ZodArray; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - logging: z.ZodOptional>>; - completions: z.ZodOptional>>; - prompts: z.ZodOptional; - }, z.core.$strip>>; - resources: z.ZodOptional; - listChanged: z.ZodOptional; - }, z.core.$strip>>; - tools: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - serverInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - instructions: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export type ElicitRequest = Infer; - -// @public (undocumented) -export type ElicitRequestFormParams = Infer; - -// @public -const ElicitRequestFormParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodOptional>; - message: z.ZodString; - requestedSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodRecord; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; - }, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>]>]>, z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>]>>; - required: z.ZodOptional>; - }, z.core.$catchall>; -}, z.core.$strip>; - -// @public (undocumented) -export type ElicitRequestParams = Infer; - -// @public -const ElicitRequestParamsSchema: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodOptional>; - message: z.ZodString; - requestedSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodRecord; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; - }, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>]>]>, z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>]>>; - required: z.ZodOptional>; - }, z.core.$catchall>; -}, z.core.$strip>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodLiteral<"url">; - message: z.ZodString; - elicitationId: z.ZodString; - url: z.ZodString; -}, z.core.$strip>]>; - -// @public -const ElicitRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"elicitation/create">; - params: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodOptional>; - message: z.ZodString; - requestedSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodRecord; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; - }, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>]>]>, z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>]>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - }, z.core.$strip>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodLiteral<"url">; - message: z.ZodString; - elicitationId: z.ZodString; - url: z.ZodString; - }, z.core.$strip>]>; -}, z.core.$strip>; - -// @public (undocumented) -export type ElicitRequestURLParams = Infer; - -// @public -const ElicitRequestURLParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodLiteral<"url">; - message: z.ZodString; - elicitationId: z.ZodString; - url: z.ZodString; -}, z.core.$strip>; - -// @public (undocumented) -export type ElicitResult = StripWireOnly>; - -// @public -const ElicitResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - action: z.ZodEnum<{ - cancel: "cancel"; - accept: "accept"; - decline: "decline"; - }>; - content: z.ZodPipe, z.ZodOptional]>>>>; -}, z.core.$loose>; - -// @public (undocumented) -export type ElicitationCompleteNotification = Infer; - -// @public (undocumented) -export type ElicitationCompleteNotificationParams = Infer; - -// @public -const ElicitationCompleteNotificationParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - elicitationId: z.ZodString; -}, z.core.$strip>; - -// @public -const ElicitationCompleteNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/elicitation/complete">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - elicitationId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type EmbeddedResource = Infer; - -// @public -const EmbeddedResourceSchema: z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type EmptyResult = StripWireOnly>; - -// @public -const EmptyResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$strict>; - -// @public (undocumented) -export type EnumSchema = Infer; - -// @public -const EnumSchemaSchema: z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; -}, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>]>]>; - -// @public -type ExpandRecursively = T extends object ? (T extends infer O ? { [K in keyof O]: ExpandRecursively } : never) : T; - -// @public (undocumented) -export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; - -// @public (undocumented) -type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; - -// @public (undocumented) -export type GetPromptRequest = Infer; - -// @public (undocumented) -export type GetPromptRequestParams = Infer; - -// @public -const GetPromptRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - name: z.ZodString; - arguments: z.ZodOptional>; -}, z.core.$strip>; - -// @public -const GetPromptRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"prompts/get">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - name: z.ZodString; - arguments: z.ZodOptional>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type GetPromptResult = StripWireOnly>; - -// @public -const GetPromptResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - description: z.ZodOptional; - messages: z.ZodArray; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type GetTaskPayloadRequest = Infer; - -// @public @deprecated -const GetTaskPayloadRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"tasks/result">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type GetTaskPayloadResult = StripWireOnly>; - -// @public @deprecated -const GetTaskPayloadResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type GetTaskRequest = Infer; - -// @public @deprecated -const GetTaskRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"tasks/get">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type GetTaskResult = StripWireOnly>; - -// @public @deprecated -const GetTaskResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] }; - -// @public (undocumented) -export const INTERNAL_ERROR = -32603; - -// @public (undocumented) -export const INVALID_PARAMS = -32602; - -// @public (undocumented) -export const INVALID_REQUEST = -32600; - -// @public (undocumented) -export type Icon = Infer; - -// @public -const IconSchema: z.ZodObject<{ - src: z.ZodString; - mimeType: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type Icons = Infer; - -// @public -const IconsSchema: z.ZodObject<{ - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; -}, z.core.$strip>; - -// @public (undocumented) -export type ImageContent = Infer; - -// @public -const ImageContentSchema: z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type Implementation = Infer; - -// @public -const ImplementationSchema: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public -export class InMemoryTransport implements Transport { - // (undocumented) - close(): Promise; - static createLinkedPair(): [InMemoryTransport, InMemoryTransport]; - // (undocumented) - onclose?: () => void; - // (undocumented) - onerror?: (error: Error) => void; - // (undocumented) - onmessage?: (message: JSONRPCMessage, extra?: { - authInfo?: AuthInfo; - }) => void; - send(message: JSONRPCMessage, options?: { - relatedRequestId?: RequestId; - authInfo?: AuthInfo; - }): Promise; - // (undocumented) - sessionId?: string; - // (undocumented) - start(): Promise; -} - -// @public (undocumented) -type Infer = Flatten>; - -// @public (undocumented) -type InferHandlerResult = R extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : Result; - -// @public (undocumented) -export type InitializeRequest = Infer; - -// @public (undocumented) -export type InitializeRequestParams = Infer; - -// @public (undocumented) -const InitializeRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - protocolVersion: z.ZodString; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - sampling: z.ZodOptional>>; - tools: z.ZodOptional>>; - }, z.core.$strip>>; - elicitation: z.ZodOptional, z.ZodIntersection; - }, z.core.$strip>, z.ZodType>>>; - url: z.ZodOptional>>; - }, z.core.$strip>, z.ZodOptional>>>>>; - roots: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - elicitation: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - clientInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public -const InitializeRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"initialize">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - protocolVersion: z.ZodString; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - sampling: z.ZodOptional>>; - tools: z.ZodOptional>>; - }, z.core.$strip>>; - elicitation: z.ZodOptional, z.ZodIntersection; - }, z.core.$strip>, z.ZodType>>>; - url: z.ZodOptional>>; - }, z.core.$strip>, z.ZodOptional>>>>>; - roots: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - elicitation: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - clientInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type InitializeResult = StripWireOnly>; - -// @public -const InitializeResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - protocolVersion: z.ZodString; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - logging: z.ZodOptional>>; - completions: z.ZodOptional>>; - prompts: z.ZodOptional; - }, z.core.$strip>>; - resources: z.ZodOptional; - listChanged: z.ZodOptional; - }, z.core.$strip>>; - tools: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - serverInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - instructions: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export type InitializedNotification = Infer; - -// @public -const InitializedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/initialized">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export interface InternalError extends JSONRPCErrorObject { - // (undocumented) - code: typeof INTERNAL_ERROR; -} - -// @public (undocumented) -export interface InvalidParamsError extends JSONRPCErrorObject { - // (undocumented) - code: typeof INVALID_PARAMS; -} - -// @public (undocumented) -export interface InvalidRequestError extends JSONRPCErrorObject { - // (undocumented) - code: typeof INVALID_REQUEST; -} - -// @public (undocumented) -export type JSONArray = JSONValue[]; - -// @public (undocumented) -export type JSONObject = { - [key: string]: JSONValue; -}; - -// @public (undocumented) -type JSONRPCErrorObject = { - code: number; - message: string; - data?: unknown; -}; - -// @public (undocumented) -export type JSONRPCErrorResponse = Infer; - -// @public -const JSONRPCErrorResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>; - -// @public (undocumented) -export type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; - -// @public (undocumented) -export type JSONRPCNotification = Infer; - -// @public -const JSONRPCNotificationSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>; - -// @public (undocumented) -export type JSONRPCRequest = Infer; - -// @public -const JSONRPCRequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>; - -// @public (undocumented) -export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; - -// @public (undocumented) -export type JSONRPCResultResponse = Omit, 'result'> & { - result: Result; -}; - -// @public -const JSONRPCResultResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>; - -// @public (undocumented) -export const JSONRPC_VERSION = "2.0"; - -// @public (undocumented) -export type JSONValue = string | number | boolean | null | JSONObject | JSONArray; - -// @public -export type JsonSchemaType = JSONSchema.Interface; - -// @public -export type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -export type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public -export interface JwtAuthGrantResult { - expiresIn?: number; - jwtAuthGrant: string; - scope?: string; -} - -// @public (undocumented) -export const LATEST_PROTOCOL_VERSION = "2025-11-25"; - -// @public @deprecated -export const LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel"; - -// @public (undocumented) -export type LegacyTitledEnumSchema = Infer; - -// @public -const LegacyTitledEnumSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public -export type ListChangedCallback = (error: Error | null, items: T[] | null) => void; - -// @public -export type ListChangedHandlers = { - tools?: ListChangedOptions; - prompts?: ListChangedOptions; - resources?: ListChangedOptions; -}; - -// @public -export type ListChangedOptions = { - autoRefresh?: boolean; - debounceMs?: number; - onChanged: ListChangedCallback; -}; - -// @public (undocumented) -export type ListPromptsRequest = Infer; - -// @public -const ListPromptsRequestSchema: z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"prompts/list">; -}, z.core.$strip>; - -// @public (undocumented) -export type ListPromptsResult = StripWireOnly>; - -// @public -const ListPromptsResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - prompts: z.ZodArray; - arguments: z.ZodOptional; - required: z.ZodOptional; - }, z.core.$strip>>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public (undocumented) -export type ListResourceTemplatesRequest = Infer; - -// @public -const ListResourceTemplatesRequestSchema: z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"resources/templates/list">; -}, z.core.$strip>; - -// @public (undocumented) -export type ListResourceTemplatesResult = StripWireOnly>; - -// @public -const ListResourceTemplatesResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - resourceTemplates: z.ZodArray; - mimeType: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public (undocumented) -export type ListResourcesRequest = Infer; - -// @public -const ListResourcesRequestSchema: z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"resources/list">; -}, z.core.$strip>; - -// @public (undocumented) -export type ListResourcesResult = StripWireOnly>; - -// @public -const ListResourcesResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - resources: z.ZodArray; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public (undocumented) -export type ListRootsRequest = Infer; - -// @public -const ListRootsRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"roots/list">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type ListRootsResult = StripWireOnly>; - -// @public -const ListRootsResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - roots: z.ZodArray; - _meta: z.ZodOptional>; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type ListTasksRequest = Infer; - -// @public @deprecated -const ListTasksRequestSchema: z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"tasks/list">; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type ListTasksResult = StripWireOnly>; - -// @public @deprecated -const ListTasksResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - tasks: z.ZodArray; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public (undocumented) -export type ListToolsRequest = Infer; - -// @public -const ListToolsRequestSchema: z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"tools/list">; -}, z.core.$strip>; - -// @public (undocumented) -export type ListToolsResult = StripWireOnly>; - -// @public -const ListToolsResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - tools: z.ZodArray; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public (undocumented) -export type LoggingLevel = Infer; - -// @public -const LoggingLevelSchema: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; -}>; - -// @public (undocumented) -export type LoggingMessageNotification = Infer; - -// @public (undocumented) -export type LoggingMessageNotificationParams = Infer; - -// @public -const LoggingMessageNotificationParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; - logger: z.ZodOptional; - data: z.ZodUnknown; -}, z.core.$strip>; - -// @public -const LoggingMessageNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/message">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; - logger: z.ZodOptional; - data: z.ZodUnknown; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public -export type LoggingOptions = { - logger?: RequestLogger; - includeRequestHeaders?: boolean; - includeResponseHeaders?: boolean; - statusLevel?: number; -}; - -// @public (undocumented) -export const METHOD_NOT_FOUND = -32601; - -// @public -export interface MessageExtraInfo { - authInfo?: AuthInfo; - closeSSEStream?: () => void; - closeStandaloneSSEStream?: () => void; - request?: globalThis.Request; -} - -// @public (undocumented) -export type MetaObject = Record; - -// @public (undocumented) -export interface MethodNotFoundError extends JSONRPCErrorObject { - // (undocumented) - code: typeof METHOD_NOT_FOUND; -} - -// @public (undocumented) -type MethodToTypeMap = { [T in U as T extends { - method: infer M extends string; - } ? M : never]: T }; - -// @public -export type Middleware = (next: FetchLike) => FetchLike; - -// @public (undocumented) -export type ModelHint = Infer; - -// @public -const ModelHintSchema: z.ZodObject<{ - name: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ModelPreferences = Infer; - -// @public -const ModelPreferencesSchema: z.ZodObject<{ - hints: z.ZodOptional; - }, z.core.$strip>>>; - costPriority: z.ZodOptional; - speedPriority: z.ZodOptional; - intelligencePriority: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type MultiSelectEnumSchema = Infer; - -// @public -const MultiSelectEnumSchemaSchema: z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type NotificationMethod = Exclude; - -// @public -type NotificationOptions_2 = { - relatedRequestId?: RequestId; -}; -export { NotificationOptions_2 as NotificationOptions } - -// @public (undocumented) -export type NotificationParams = Infer; - -// @public (undocumented) -const NotificationSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; -}, z.core.$strip>; - -// @public (undocumented) -export type NotificationTypeMap = MethodToTypeMap>; - -// @public (undocumented) -type Notification_2 = Infer; -export { Notification_2 as Notification } - -// @public (undocumented) -const NotificationsParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; -}, z.core.$strip>; - -// @public (undocumented) -export type NumberSchema = Infer; - -// @public -const NumberSchemaSchema: z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthClientInformation = z.infer; - -// @public (undocumented) -export type OAuthClientInformationFull = z.infer; - -// @public -const OAuthClientInformationFullSchema: z.ZodObject<{ - redirect_uris: z.ZodArray; - token_endpoint_auth_method: z.ZodOptional; - grant_types: z.ZodOptional>; - response_types: z.ZodOptional>; - client_name: z.ZodOptional; - client_uri: z.ZodOptional; - logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - scope: z.ZodOptional; - contacts: z.ZodOptional>; - tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - policy_uri: z.ZodOptional; - jwks_uri: z.ZodOptional; - jwks: z.ZodOptional; - software_id: z.ZodOptional; - software_version: z.ZodOptional; - software_statement: z.ZodOptional; - client_id: z.ZodString; - client_secret: z.ZodOptional; - client_id_issued_at: z.ZodOptional; - client_secret_expires_at: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthClientInformationMixed = OAuthClientInformation | OAuthClientInformationFull; - -// @public -const OAuthClientInformationSchema: z.ZodObject<{ - client_id: z.ZodString; - client_secret: z.ZodOptional; - client_id_issued_at: z.ZodOptional; - client_secret_expires_at: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthClientMetadata = z.infer; - -// @public -const OAuthClientMetadataSchema: z.ZodObject<{ - redirect_uris: z.ZodArray; - token_endpoint_auth_method: z.ZodOptional; - grant_types: z.ZodOptional>; - response_types: z.ZodOptional>; - client_name: z.ZodOptional; - client_uri: z.ZodOptional; - logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - scope: z.ZodOptional; - contacts: z.ZodOptional>; - tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - policy_uri: z.ZodOptional; - jwks_uri: z.ZodOptional; - jwks: z.ZodOptional; - software_id: z.ZodOptional; - software_version: z.ZodOptional; - software_statement: z.ZodOptional; -}, z.core.$strip>; - -// @public -export interface OAuthClientProvider { - addClientAuthentication?: AddClientAuthentication; - authorizationServerUrl?(): string | undefined | Promise; - clientInformation(): OAuthClientInformationMixed | undefined | Promise; - get clientMetadata(): OAuthClientMetadata; - clientMetadataUrl?: string; - codeVerifier(): string | Promise; - discoveryState?(): OAuthDiscoveryState | undefined | Promise; - invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise; - prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; - redirectToAuthorization(authorizationUrl: URL): void | Promise; - get redirectUrl(): string | URL | undefined; - resourceUrl?(): string | undefined | Promise; - saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise; - saveClientInformation?(clientInformation: OAuthClientInformationMixed): void | Promise; - saveCodeVerifier(codeVerifier: string): void | Promise; - saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; - saveResourceUrl?(resourceUrl: string): void | Promise; - saveTokens(tokens: OAuthTokens): void | Promise; - state?(): string | Promise; - tokens(): OAuthTokens | undefined | Promise; - validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; -} - -// @public (undocumented) -export type OAuthClientRegistrationError = z.infer; - -// @public -const OAuthClientRegistrationErrorSchema: z.ZodObject<{ - error: z.ZodString; - error_description: z.ZodOptional; -}, z.core.$strip>; - -// @public -export interface OAuthDiscoveryState extends OAuthServerInfo { - resourceMetadataUrl?: string; -} - -// @public -export class OAuthError extends Error { - constructor(code: OAuthErrorCode | string, message: string, errorUri?: string | undefined); - // (undocumented) - readonly code: OAuthErrorCode | string; - // (undocumented) - readonly errorUri?: string | undefined; - static fromResponse(response: OAuthErrorResponse): OAuthError; - toResponseObject(): OAuthErrorResponse; -} - -// @public -export enum OAuthErrorCode { - AccessDenied = "access_denied", - InsufficientScope = "insufficient_scope", - InvalidClient = "invalid_client", - InvalidClientMetadata = "invalid_client_metadata", - InvalidGrant = "invalid_grant", - InvalidRequest = "invalid_request", - InvalidScope = "invalid_scope", - InvalidTarget = "invalid_target", - InvalidToken = "invalid_token", - MethodNotAllowed = "method_not_allowed", - ServerError = "server_error", - TemporarilyUnavailable = "temporarily_unavailable", - TooManyRequests = "too_many_requests", - UnauthorizedClient = "unauthorized_client", - UnsupportedGrantType = "unsupported_grant_type", - UnsupportedResponseType = "unsupported_response_type", - UnsupportedTokenType = "unsupported_token_type", -} - -// @public (undocumented) -export type OAuthErrorResponse = z.infer; - -// @public -const OAuthErrorResponseSchema: z.ZodObject<{ - error: z.ZodString; - error_description: z.ZodOptional; - error_uri: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthMetadata = z.infer; - -// @public -const OAuthMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - revocation_endpoint: z.ZodOptional; - revocation_endpoint_auth_methods_supported: z.ZodOptional>; - revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - introspection_endpoint: z.ZodOptional; - introspection_endpoint_auth_methods_supported: z.ZodOptional>; - introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - code_challenge_methods_supported: z.ZodOptional>; - client_id_metadata_document_supported: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export type OAuthProtectedResourceMetadata = z.infer; - -// @public -const OAuthProtectedResourceMetadataSchema: z.ZodObject<{ - resource: z.ZodString; - authorization_servers: z.ZodOptional>; - jwks_uri: z.ZodOptional; - scopes_supported: z.ZodOptional>; - bearer_methods_supported: z.ZodOptional>; - resource_signing_alg_values_supported: z.ZodOptional>; - resource_name: z.ZodOptional; - resource_documentation: z.ZodOptional; - resource_policy_uri: z.ZodOptional; - resource_tos_uri: z.ZodOptional; - tls_client_certificate_bound_access_tokens: z.ZodOptional; - authorization_details_types_supported: z.ZodOptional>; - dpop_signing_alg_values_supported: z.ZodOptional>; - dpop_bound_access_tokens_required: z.ZodOptional; -}, z.core.$loose>; - -// @public -export interface OAuthServerInfo { - authorizationServerMetadata?: AuthorizationServerMetadata; - authorizationServerUrl: string; - resourceMetadata?: OAuthProtectedResourceMetadata; -} - -// @public (undocumented) -export type OAuthTokenRevocationRequest = z.infer; - -// @public -const OAuthTokenRevocationRequestSchema: z.ZodObject<{ - token: z.ZodString; - token_type_hint: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthTokens = z.infer; - -// @public -const OAuthTokensSchema: z.ZodObject<{ - access_token: z.ZodString; - id_token: z.ZodOptional; - token_type: z.ZodString; - expires_in: z.ZodOptional>; - scope: z.ZodOptional; - refresh_token: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OpenIdProviderDiscoveryMetadata = z.infer; - -// @public -const OpenIdProviderDiscoveryMetadataSchema: z.ZodObject<{ - code_challenge_methods_supported: z.ZodOptional>; - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - userinfo_endpoint: z.ZodOptional; - jwks_uri: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - acr_values_supported: z.ZodOptional>; - subject_types_supported: z.ZodArray; - id_token_signing_alg_values_supported: z.ZodArray; - id_token_encryption_alg_values_supported: z.ZodOptional>; - id_token_encryption_enc_values_supported: z.ZodOptional>; - userinfo_signing_alg_values_supported: z.ZodOptional>; - userinfo_encryption_alg_values_supported: z.ZodOptional>; - userinfo_encryption_enc_values_supported: z.ZodOptional>; - request_object_signing_alg_values_supported: z.ZodOptional>; - request_object_encryption_alg_values_supported: z.ZodOptional>; - request_object_encryption_enc_values_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - display_values_supported: z.ZodOptional>; - claim_types_supported: z.ZodOptional>; - claims_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - claims_locales_supported: z.ZodOptional>; - ui_locales_supported: z.ZodOptional>; - claims_parameter_supported: z.ZodOptional; - request_parameter_supported: z.ZodOptional; - request_uri_parameter_supported: z.ZodOptional; - require_request_uri_registration: z.ZodOptional; - op_policy_uri: z.ZodOptional; - op_tos_uri: z.ZodOptional; - client_id_metadata_document_supported: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OpenIdProviderMetadata = z.infer; - -// @public -const OpenIdProviderMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - userinfo_endpoint: z.ZodOptional; - jwks_uri: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - acr_values_supported: z.ZodOptional>; - subject_types_supported: z.ZodArray; - id_token_signing_alg_values_supported: z.ZodArray; - id_token_encryption_alg_values_supported: z.ZodOptional>; - id_token_encryption_enc_values_supported: z.ZodOptional>; - userinfo_signing_alg_values_supported: z.ZodOptional>; - userinfo_encryption_alg_values_supported: z.ZodOptional>; - userinfo_encryption_enc_values_supported: z.ZodOptional>; - request_object_signing_alg_values_supported: z.ZodOptional>; - request_object_encryption_alg_values_supported: z.ZodOptional>; - request_object_encryption_enc_values_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - display_values_supported: z.ZodOptional>; - claim_types_supported: z.ZodOptional>; - claims_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - claims_locales_supported: z.ZodOptional>; - ui_locales_supported: z.ZodOptional>; - claims_parameter_supported: z.ZodOptional; - request_parameter_supported: z.ZodOptional; - request_uri_parameter_supported: z.ZodOptional; - require_request_uri_registration: z.ZodOptional; - op_policy_uri: z.ZodOptional; - op_tos_uri: z.ZodOptional; - client_id_metadata_document_supported: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export const PARSE_ERROR = -32700; - -// @public -export const PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"; - -// @public (undocumented) -export type PaginatedRequest = Infer; - -// @public (undocumented) -export type PaginatedRequestParams = Infer; - -// @public (undocumented) -const PaginatedRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -const PaginatedRequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type PaginatedResult = StripWireOnly>; - -// @public (undocumented) -const PaginatedResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export interface ParseError extends JSONRPCErrorObject { - // (undocumented) - code: typeof PARSE_ERROR; -} - -// @public (undocumented) -export type PingRequest = Infer; - -// @public -const PingRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"ping">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -type Primitive = string | number | boolean | bigint | null | undefined; - -// @public (undocumented) -export type PrimitiveSchemaDefinition = Infer; - -// @public -const PrimitiveSchemaDefinitionSchema: z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; -}, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>]>]>, z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; -}, z.core.$strip>]>; - -// @public -export class PrivateKeyJwtProvider implements OAuthClientProvider { - constructor(options: PrivateKeyJwtProviderOptions); - // (undocumented) - addClientAuthentication: AddClientAuthentication; - // (undocumented) - clientInformation(): OAuthClientInformation; - // (undocumented) - get clientMetadata(): OAuthClientMetadata; - // (undocumented) - codeVerifier(): string; - // (undocumented) - prepareTokenRequest(scope?: string): URLSearchParams; - // (undocumented) - redirectToAuthorization(): void; - // (undocumented) - get redirectUrl(): undefined; - // (undocumented) - saveClientInformation(info: OAuthClientInformation): void; - // (undocumented) - saveCodeVerifier(): void; - // (undocumented) - saveTokens(tokens: OAuthTokens): void; - // (undocumented) - tokens(): OAuthTokens | undefined; -} - -// @public -export interface PrivateKeyJwtProviderOptions { - algorithm: string; - claims?: Record; - clientId: string; - clientName?: string; - jwtLifetimeSeconds?: number; - privateKey: string | Uint8Array | Record; - scope?: string; -} - -// @public (undocumented) -export type Progress = Infer; - -// @public -export type ProgressCallback = (progress: Progress) => void; - -// @public (undocumented) -export type ProgressNotification = Infer; - -// @public (undocumented) -export type ProgressNotificationParams = Infer; - -// @public (undocumented) -const ProgressNotificationParamsSchema: z.ZodObject<{ - progressToken: z.ZodUnion; - progress: z.ZodNumber; - total: z.ZodOptional; - message: z.ZodOptional; - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; -}, z.core.$strip>; - -// @public -const ProgressNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/progress">; - params: z.ZodObject<{ - progressToken: z.ZodUnion; - progress: z.ZodNumber; - total: z.ZodOptional; - message: z.ZodOptional; - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -const ProgressSchema: z.ZodObject<{ - progress: z.ZodNumber; - total: z.ZodOptional; - message: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ProgressToken = Infer; - -// @public -const ProgressTokenSchema: z.ZodUnion; - -// @public (undocumented) -export type Prompt = Infer; - -// @public (undocumented) -export type PromptArgument = Infer; - -// @public -const PromptArgumentSchema: z.ZodObject<{ - name: z.ZodString; - description: z.ZodOptional; - required: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type PromptListChangedNotification = Infer; - -// @public -const PromptListChangedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/prompts/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type PromptMessage = Infer; - -// @public -const PromptMessageSchema: z.ZodObject<{ - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>; -}, z.core.$strip>; - -// @public (undocumented) -export type PromptReference = Infer; - -// @public -const PromptReferenceSchema: z.ZodObject<{ - type: z.ZodLiteral<"ref/prompt">; - name: z.ZodString; -}, z.core.$strip>; - -// @public -const PromptSchema: z.ZodObject<{ - description: z.ZodOptional; - arguments: z.ZodOptional; - required: z.ZodOptional; - }, z.core.$strip>>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public -abstract class Protocol { - constructor(_options?: ProtocolOptions | undefined); - assertCanSetRequestHandler(method: RequestMethod | string): void; - protected abstract assertCapabilityForMethod(method: RequestMethod | string): void; - protected abstract assertNotificationCapability(method: NotificationMethod | string): void; - protected abstract assertRequestHandlerCapability(method: string): void; - protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; - close(): Promise; - connect(transport: Transport): Promise; - fallbackNotificationHandler?: (notification: Notification_2) => Promise; - fallbackRequestHandler?: (request: JSONRPCRequest, ctx: ContextT) => Promise; - notification(notification: Notification_2, options?: NotificationOptions_2): Promise; - onclose?: () => void; - onerror?: (error: Error) => void; - removeNotificationHandler(method: NotificationMethod | string): void; - removeRequestHandler(method: RequestMethod | string): void; - request(request: { - method: M; - params?: Record; - }, options?: RequestOptions): Promise; - // (undocumented) - request(request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; - protected _requestWithSchema(request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; - setNotificationHandler(method: M, handler: (notification: NotificationTypeMap[M]) => void | Promise): void; - // (undocumented) - setNotificationHandler

(method: string, schemas: { - params: P; - }, handler: (params: StandardSchemaV1.InferOutput

, notification: Notification_2) => void | Promise): void; - setRequestHandler(method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => ResultTypeMap[M] | Promise): void; - // (undocumented) - setRequestHandler

(method: string, schemas: { - params: P; - result?: R; - }, handler: (params: StandardSchemaV1.InferOutput

, ctx: ContextT) => InferHandlerResult | Promise>): void; - // (undocumented) - protected _supportedProtocolVersions: string[]; - // (undocumented) - get transport(): Transport | undefined; - protected _wrapHandler(_method: string, handler: (request: JSONRPCRequest, ctx: ContextT) => Promise): (request: JSONRPCRequest, ctx: ContextT) => Promise; -} - -// @public -export class ProtocolError extends Error { - constructor(code: number, message: string, data?: unknown | undefined); - // (undocumented) - readonly code: number; - // (undocumented) - readonly data?: unknown | undefined; - static fromError(code: number, message: string, data?: unknown): ProtocolError; -} - -// @public -export enum ProtocolErrorCode { - // (undocumented) - InternalError = -32603, - // (undocumented) - InvalidParams = -32602, - // (undocumented) - InvalidRequest = -32600, - // (undocumented) - MethodNotFound = -32601, - MissingRequiredClientCapability = -32003, - // (undocumented) - ParseError = -32700, - // (undocumented) - ResourceNotFound = -32002, - UnsupportedProtocolVersion = -32004, - // (undocumented) - UrlElicitationRequired = -32042, -} - -// @public -export type ProtocolOptions = { - supportedProtocolVersions?: string[]; - enforceStrictCapabilities?: boolean; - debouncedNotificationMethods?: string[]; -}; - -// @public (undocumented) -type ProtocolSchemaKey = (typeof SPEC_SCHEMA_KEYS)[number]; - -// @public @deprecated -export const RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; - -// @public -export class ReadBuffer { - constructor(options?: { - maxBufferSize?: number; - }); - // (undocumented) - append(chunk: Buffer): void; - // (undocumented) - clear(): void; - // (undocumented) - readMessage(): JSONRPCMessage | null; -} - -// @public (undocumented) -export type ReadResourceRequest = Infer; - -// @public (undocumented) -export type ReadResourceRequestParams = Infer; - -// @public -const ReadResourceRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const ReadResourceRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"resources/read">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type ReadResourceResult = StripWireOnly>; - -// @public -const ReadResourceResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - contents: z.ZodArray; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>>; -}, z.core.$loose>; - -// @public -export type ReconnectionScheduler = (reconnect: () => void, delay: number, attemptCount: number) => (() => void) | void; - -// @public @deprecated (undocumented) -export type RelatedTaskMetadata = Infer; - -// @public @deprecated -const RelatedTaskMetadataSchema: z.ZodObject<{ - taskId: z.ZodString; -}, z.core.$strip>; - -// @public -export interface RequestHandlerSchemas

{ - // (undocumented) - params: P; - // (undocumented) - result?: R; -} - -// @public (undocumented) -export type RequestId = Infer; - -// @public -const RequestIdSchema: z.ZodUnion; - -// @public -export interface RequestJwtAuthGrantOptions { - audience: string | URL; - clientId: string; - clientSecret?: string; - fetchFn?: FetchLike; - idToken: string; - resource: string | URL; - scope?: string; - tokenEndpoint: string | URL; -} - -// @public -export type RequestLogger = (input: { - method: string; - url: string | URL; - status: number; - statusText: string; - duration: number; - requestHeaders?: Headers; - responseHeaders?: Headers; - error?: Error; -}) => void; - -// @public (undocumented) -export type RequestMeta = Infer; - -// @public -export type RequestMetaEnvelope = Infer; - -// @public -const RequestMetaEnvelopeSchema: z.ZodObject<{ - progressToken: z.ZodOptional>; - "io.modelcontextprotocol/protocolVersion": z.ZodString; - "io.modelcontextprotocol/clientInfo": z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - "io.modelcontextprotocol/clientCapabilities": z.ZodObject<{ - experimental: z.ZodOptional>>>; - sampling: z.ZodOptional>>; - tools: z.ZodOptional>>; - }, z.core.$strip>>; - elicitation: z.ZodOptional, z.ZodIntersection; - }, z.core.$strip>, z.ZodType>>>; - url: z.ZodOptional>>; - }, z.core.$strip>, z.ZodOptional>>>>>; - roots: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - elicitation: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - "io.modelcontextprotocol/logLevel": z.ZodOptional>; -}, z.core.$loose>; - -// @public (undocumented) -export type RequestMetaObject = RequestMeta; - -// @public (undocumented) -const RequestMetaSchema: z.ZodObject<{ - progressToken: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; -}, z.core.$loose>; - -// @public (undocumented) -export type RequestMethod = Exclude; - -// @public -export type RequestOptions = { - onprogress?: ProgressCallback; - signal?: AbortSignal; - timeout?: number; - resetTimeoutOnProgress?: boolean; - maxTotalTimeout?: number; -} & TransportSendOptions; - -// @public (undocumented) -export type RequestParams = Infer; - -// @public (undocumented) -const RequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; -}, z.core.$strip>; - -// @public (undocumented) -export type RequestTypeMap = MethodToTypeMap>; - -// @public (undocumented) -type Request_2 = Infer; -export { Request_2 as Request } - -// @public (undocumented) -export type Resource = Infer; - -// @public (undocumented) -export type ResourceContents = Infer; - -// @public -const ResourceContentsSchema: z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type ResourceLink = Infer; - -// @public -const ResourceLinkSchema: z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; -}, z.core.$strip>; - -// @public (undocumented) -export type ResourceListChangedNotification = Infer; - -// @public -const ResourceListChangedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/resources/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type ResourceRequestParams = Infer; - -// @public (undocumented) -const ResourceRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const ResourceSchema: z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ResourceTemplateReference = Infer; - -// @public -const ResourceTemplateReferenceSchema: z.ZodObject<{ - type: z.ZodLiteral<"ref/resource">; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const ResourceTemplateSchema: z.ZodObject<{ - uriTemplate: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ResourceTemplateType = Infer; - -// @public (undocumented) -export type ResourceUpdatedNotification = Infer; - -// @public (undocumented) -export type ResourceUpdatedNotificationParams = Infer; - -// @public -const ResourceUpdatedNotificationParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const ResourceUpdatedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/resources/updated">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type Result = StripWireOnly>; - -// @public (undocumented) -const ResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export type ResultTypeMap = { - ping: EmptyResult; - initialize: InitializeResult; - 'completion/complete': CompleteResult; - 'logging/setLevel': EmptyResult; - 'prompts/get': GetPromptResult; - 'prompts/list': ListPromptsResult; - 'resources/list': ListResourcesResult; - 'resources/templates/list': ListResourceTemplatesResult; - 'resources/read': ReadResourceResult; - 'resources/subscribe': EmptyResult; - 'resources/unsubscribe': EmptyResult; - 'tools/call': CallToolResult; - 'tools/list': ListToolsResult; - 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools; - 'elicitation/create': ElicitResult; - 'roots/list': ListRootsResult; -}; - -// @public (undocumented) -export type Role = Infer; - -// @public -const RoleSchema: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; -}>; - -// @public (undocumented) -export type Root = Infer; - -// @public -const RootSchema: z.ZodObject<{ - uri: z.ZodString; - name: z.ZodOptional; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type RootsListChangedNotification = Infer; - -// @public -const RootsListChangedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/roots/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public -const SPEC_SCHEMA_KEYS: readonly ["AnnotationsSchema", "AudioContentSchema", "BaseMetadataSchema", "BlobResourceContentsSchema", "BooleanSchemaSchema", "CallToolRequestSchema", "CallToolRequestParamsSchema", "CallToolResultSchema", "CancelledNotificationSchema", "CancelledNotificationParamsSchema", "CancelTaskRequestSchema", "CancelTaskResultSchema", "ClientCapabilitiesSchema", "ClientNotificationSchema", "ClientRequestSchema", "ClientResultSchema", "CompatibilityCallToolResultSchema", "CompleteRequestSchema", "CompleteRequestParamsSchema", "CompleteResultSchema", "ContentBlockSchema", "CreateMessageRequestSchema", "CreateMessageRequestParamsSchema", "CreateMessageResultSchema", "CreateMessageResultWithToolsSchema", "CreateTaskResultSchema", "CursorSchema", "DiscoverRequestSchema", "DiscoverResultSchema", "ElicitationCompleteNotificationSchema", "ElicitationCompleteNotificationParamsSchema", "ElicitRequestSchema", "ElicitRequestFormParamsSchema", "ElicitRequestParamsSchema", "ElicitRequestURLParamsSchema", "ElicitResultSchema", "EmbeddedResourceSchema", "EmptyResultSchema", "EnumSchemaSchema", "GetPromptRequestSchema", "GetPromptRequestParamsSchema", "GetPromptResultSchema", "GetTaskPayloadRequestSchema", "GetTaskPayloadResultSchema", "GetTaskRequestSchema", "GetTaskResultSchema", "IconSchema", "IconsSchema", "ImageContentSchema", "ImplementationSchema", "InitializedNotificationSchema", "InitializeRequestSchema", "InitializeRequestParamsSchema", "InitializeResultSchema", "JSONArraySchema", "JSONObjectSchema", "JSONRPCErrorResponseSchema", "JSONRPCMessageSchema", "JSONRPCNotificationSchema", "JSONRPCRequestSchema", "JSONRPCResponseSchema", "JSONRPCResultResponseSchema", "JSONValueSchema", "LegacyTitledEnumSchemaSchema", "ListPromptsRequestSchema", "ListPromptsResultSchema", "ListResourcesRequestSchema", "ListResourcesResultSchema", "ListResourceTemplatesRequestSchema", "ListResourceTemplatesResultSchema", "ListRootsRequestSchema", "ListRootsResultSchema", "ListTasksRequestSchema", "ListTasksResultSchema", "ListToolsRequestSchema", "ListToolsResultSchema", "LoggingLevelSchema", "LoggingMessageNotificationSchema", "LoggingMessageNotificationParamsSchema", "ModelHintSchema", "ModelPreferencesSchema", "MultiSelectEnumSchemaSchema", "NotificationSchema", "NumberSchemaSchema", "PaginatedRequestSchema", "PaginatedRequestParamsSchema", "PaginatedResultSchema", "PingRequestSchema", "PrimitiveSchemaDefinitionSchema", "ProgressSchema", "ProgressNotificationSchema", "ProgressNotificationParamsSchema", "ProgressTokenSchema", "PromptSchema", "PromptArgumentSchema", "PromptListChangedNotificationSchema", "PromptMessageSchema", "PromptReferenceSchema", "ReadResourceRequestSchema", "ReadResourceRequestParamsSchema", "ReadResourceResultSchema", "RelatedTaskMetadataSchema", "RequestSchema", "RequestIdSchema", "RequestMetaEnvelopeSchema", "RequestMetaSchema", "ResourceSchema", "ResourceContentsSchema", "ResourceLinkSchema", "ResourceListChangedNotificationSchema", "ResourceRequestParamsSchema", "ResourceTemplateSchema", "ResourceTemplateReferenceSchema", "ResourceUpdatedNotificationSchema", "ResourceUpdatedNotificationParamsSchema", "ResultSchema", "RoleSchema", "RootSchema", "RootsListChangedNotificationSchema", "SamplingContentSchema", "SamplingMessageSchema", "SamplingMessageContentBlockSchema", "ServerCapabilitiesSchema", "ServerNotificationSchema", "ServerRequestSchema", "ServerResultSchema", "SetLevelRequestSchema", "SetLevelRequestParamsSchema", "SingleSelectEnumSchemaSchema", "StringSchemaSchema", "SubscribeRequestSchema", "SubscribeRequestParamsSchema", "TaskSchema", "TaskAugmentedRequestParamsSchema", "TaskCreationParamsSchema", "TaskMetadataSchema", "TaskStatusSchema", "TaskStatusNotificationSchema", "TaskStatusNotificationParamsSchema", "TextContentSchema", "TextResourceContentsSchema", "TitledMultiSelectEnumSchemaSchema", "TitledSingleSelectEnumSchemaSchema", "ToolSchema", "ToolAnnotationsSchema", "ToolChoiceSchema", "ToolExecutionSchema", "ToolListChangedNotificationSchema", "ToolResultContentSchema", "ToolUseContentSchema", "UnsubscribeRequestSchema", "UnsubscribeRequestParamsSchema", "UntitledMultiSelectEnumSchemaSchema", "UntitledSingleSelectEnumSchemaSchema"]; - -// @public @deprecated -export class SSEClientTransport implements Transport { - constructor(url: URL, opts?: SSEClientTransportOptions); - // (undocumented) - close(): Promise; - finishAuth(authorizationCode: string): Promise; - // (undocumented) - onclose?: () => void; - // (undocumented) - onerror?: (error: Error) => void; - // (undocumented) - onmessage?: (message: JSONRPCMessage) => void; - // (undocumented) - send(message: JSONRPCMessage): Promise; - // (undocumented) - setProtocolVersion(version: string): void; - // (undocumented) - start(): Promise; -} - -// @public -export type SSEClientTransportOptions = { - authProvider?: AuthProvider | OAuthClientProvider; - eventSourceInit?: EventSourceInit_2; - requestInit?: RequestInit; - fetch?: FetchLike; -}; - -// @public (undocumented) -export const STDIO_DEFAULT_MAX_BUFFER_SIZE: number; - -// @public (undocumented) -export const SUPPORTED_PROTOCOL_VERSIONS: string[]; - -// @public (undocumented) -export type SamplingContent = Infer; - -// @public -const SamplingContentSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ - type: z.ZodLiteral<"text">; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>], "type">; - -// @public (undocumented) -export type SamplingMessage = Infer; - -// @public (undocumented) -export type SamplingMessageContentBlock = Infer; - -// @public -const SamplingMessageContentBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ - type: z.ZodLiteral<"text">; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; -}, z.core.$strip>], "type">; - -// @public -const SamplingMessageSchema: z.ZodObject<{ - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -type SchemaFor = K extends ProtocolSchemaKey ? (typeof schemas_d_exports)[K] : K extends AuthSchemaKey ? (typeof authSchemas)[K] : never; - -// @public (undocumented) -type SchemaKey = ProtocolSchemaKey | AuthSchemaKey; - -// @public (undocumented) -type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync }; - -// @public -export class SdkError extends Error { - constructor(code: SdkErrorCode, message: string, data?: unknown | undefined); - // (undocumented) - readonly code: SdkErrorCode; - // (undocumented) - readonly data?: unknown | undefined; -} - -// @public -export enum SdkErrorCode { - AlreadyConnected = "ALREADY_CONNECTED", - CapabilityNotSupported = "CAPABILITY_NOT_SUPPORTED", - // (undocumented) - ClientHttpAuthentication = "CLIENT_HTTP_AUTHENTICATION", - // (undocumented) - ClientHttpFailedToOpenStream = "CLIENT_HTTP_FAILED_TO_OPEN_STREAM", - // (undocumented) - ClientHttpFailedToTerminateSession = "CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION", - // (undocumented) - ClientHttpForbidden = "CLIENT_HTTP_FORBIDDEN", - // (undocumented) - ClientHttpNotImplemented = "CLIENT_HTTP_NOT_IMPLEMENTED", - // (undocumented) - ClientHttpUnexpectedContent = "CLIENT_HTTP_UNEXPECTED_CONTENT", - ConnectionClosed = "CONNECTION_CLOSED", - InvalidResult = "INVALID_RESULT", - NotConnected = "NOT_CONNECTED", - NotInitialized = "NOT_INITIALIZED", - RequestTimeout = "REQUEST_TIMEOUT", - SendFailed = "SEND_FAILED", - UnsupportedResultType = "UNSUPPORTED_RESULT_TYPE", -} - -// @public -export class SdkHttpError extends SdkError { - constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData); - // (undocumented) - readonly data: SdkHttpErrorData; - // (undocumented) - get status(): number; - // (undocumented) - get statusText(): string | undefined; -} - -// @public -export interface SdkHttpErrorData { - // (undocumented) - [key: string]: unknown; - // (undocumented) - status: number; - // (undocumented) - statusText?: string; -} - -// @public (undocumented) -export type ServerCapabilities = Infer; - -// @public -const ServerCapabilitiesSchema: z.ZodObject<{ - experimental: z.ZodOptional>>>; - logging: z.ZodOptional>>; - completions: z.ZodOptional>>; - prompts: z.ZodOptional; - }, z.core.$strip>>; - resources: z.ZodOptional; - listChanged: z.ZodOptional; - }, z.core.$strip>>; - tools: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; -}, z.core.$strip>; - -// @public -export type ServerContext = BaseContext & { - mcpReq: { - log: (level: LoggingLevel, data: unknown, logger?: string) => Promise; - elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; - requestSampling: (params: CreateMessageRequest['params'], options?: RequestOptions) => Promise; - }; - http?: { - req?: globalThis.Request; - closeSSE?: () => void; - closeStandaloneSSE?: () => void; - }; -}; - -// @public (undocumented) -export type ServerNotification = Infer; - -// @public (undocumented) -const ServerNotificationSchema: z.ZodUnion; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - requestId: z.ZodOptional>; - reason: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/progress">; - params: z.ZodObject<{ - progressToken: z.ZodUnion; - progress: z.ZodNumber; - total: z.ZodOptional; - message: z.ZodOptional; - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/message">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; - logger: z.ZodOptional; - data: z.ZodUnknown; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/resources/updated">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/resources/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/tools/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/prompts/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/tasks/status">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/elicitation/complete">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - elicitationId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type ServerRequest = Infer; - -// @public (undocumented) -const ServerRequestSchema: z.ZodUnion; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"sampling/createMessage">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - messages: z.ZodArray; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; - _meta: z.ZodOptional>; - }, z.core.$strip>>; - modelPreferences: z.ZodOptional; - }, z.core.$strip>>>; - costPriority: z.ZodOptional; - speedPriority: z.ZodOptional; - intelligencePriority: z.ZodOptional; - }, z.core.$strip>>; - systemPrompt: z.ZodOptional; - includeContext: z.ZodOptional>; - temperature: z.ZodOptional; - maxTokens: z.ZodNumber; - stopSequences: z.ZodOptional>; - metadata: z.ZodOptional>>; - tools: z.ZodOptional; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>>; - toolChoice: z.ZodOptional>; - }, z.core.$strip>>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"elicitation/create">; - params: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodOptional>; - message: z.ZodString; - requestedSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodRecord; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; - }, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>]>]>, z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>]>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - }, z.core.$strip>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodLiteral<"url">; - message: z.ZodString; - elicitationId: z.ZodString; - url: z.ZodString; - }, z.core.$strip>]>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"roots/list">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/get">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/result">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"tasks/list">; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/cancel">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type ServerResult = StripWireOnly>; - -// @public (undocumented) -const ServerResultSchema: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$strict>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - protocolVersion: z.ZodString; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - logging: z.ZodOptional>>; - completions: z.ZodOptional>>; - prompts: z.ZodOptional; - }, z.core.$strip>>; - resources: z.ZodOptional; - listChanged: z.ZodOptional; - }, z.core.$strip>>; - tools: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - serverInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - instructions: z.ZodOptional; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - completion: z.ZodObject<{ - values: z.ZodArray; - total: z.ZodOptional; - hasMore: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - description: z.ZodOptional; - messages: z.ZodArray; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - prompts: z.ZodArray; - arguments: z.ZodOptional; - required: z.ZodOptional; - }, z.core.$strip>>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - resources: z.ZodArray; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - resourceTemplates: z.ZodArray; - mimeType: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - contents: z.ZodArray; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - tools: z.ZodArray; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - tasks: z.ZodArray; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - task: z.ZodObject<{ - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$loose>]>; - -// @public (undocumented) -export type SetLevelRequest = Infer; - -// @public (undocumented) -export type SetLevelRequestParams = Infer; - -// @public -const SetLevelRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; -}, z.core.$strip>; - -// @public -const SetLevelRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"logging/setLevel">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type SingleSelectEnumSchema = Infer; - -// @public (undocumented) -const SingleSelectEnumSchemaSchema: z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; -}, z.core.$strip>]>; - -// @public -type SpecTypeInputs = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.input> : never }; - -// @public -export type SpecTypeName = StripSchemaSuffix; - -// @public -export type SpecTypes = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.output> : never }; - -// @public (undocumented) -export class SseError extends Error { - constructor(code: number | undefined, message: string | undefined, event: ErrorEvent_2); - // (undocumented) - readonly code: number | undefined; - // (undocumented) - readonly event: ErrorEvent_2; -} - -// @public (undocumented) -interface StandardJSONSchemaV1 { - // (undocumented) - readonly '~standard': StandardJSONSchemaV1.Props; -} - -// @public (undocumented) -namespace StandardJSONSchemaV1 { - // (undocumented) - interface Converter { - // (undocumented) - readonly input: (options: Options) => Record; - // (undocumented) - readonly output: (options: Options) => Record; - } - // (undocumented) - type InferInput = StandardTypedV1.InferInput; - // (undocumented) - type InferOutput = StandardTypedV1.InferOutput; - // (undocumented) - interface Options { - // (undocumented) - readonly libraryOptions?: Record | undefined; - // (undocumented) - readonly target: Target; - } - // (undocumented) - interface Props extends StandardTypedV1.Props { - // (undocumented) - readonly jsonSchema: Converter; - } - // (undocumented) - type Target = 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | (object & string); -} - -// @public (undocumented) -export interface StandardSchemaV1 { - // (undocumented) - readonly '~standard': StandardSchemaV1.Props; -} - -// @public (undocumented) -export namespace StandardSchemaV1 { - // (undocumented) - export interface FailureResult { - // (undocumented) - readonly issues: ReadonlyArray; - } - // (undocumented) - export type InferInput = StandardTypedV1.InferInput; - // (undocumented) - export type InferOutput = StandardTypedV1.InferOutput; - // (undocumented) - export interface Issue { - // (undocumented) - readonly message: string; - // (undocumented) - readonly path?: ReadonlyArray | undefined; - } - // (undocumented) - export interface Options { - // (undocumented) - readonly libraryOptions?: Record | undefined; - } - // (undocumented) - export interface PathSegment { - // (undocumented) - readonly key: PropertyKey; - } - // (undocumented) - export interface Props extends StandardTypedV1.Props { - // (undocumented) - readonly validate: (value: unknown, options?: Options | undefined) => Result | Promise>; - } - // (undocumented) - export type Result = SuccessResult | FailureResult; - // (undocumented) - export interface SuccessResult { - // (undocumented) - readonly issues?: undefined; - // (undocumented) - readonly value: Output; - } -} - -// @public -export interface StandardSchemaV1Sync extends StandardSchemaV1 { - // (undocumented) - readonly '~standard': StandardSchemaV1Sync.Props; -} - -// @public (undocumented) -export namespace StandardSchemaV1Sync { - // (undocumented) - export type InferInput = StandardTypedV1.InferInput; - // (undocumented) - export type InferOutput = StandardTypedV1.InferOutput; - // (undocumented) - export interface Props extends StandardSchemaV1.Props { - // (undocumented) - readonly validate: (value: unknown, options?: StandardSchemaV1.Options | undefined) => StandardSchemaV1.Result; - } -} - -// @public -export interface StandardSchemaWithJSON { - // (undocumented) - readonly '~standard': StandardSchemaV1.Props & StandardJSONSchemaV1.Props; -} - -// @public (undocumented) -export namespace StandardSchemaWithJSON { - // (undocumented) - export type InferInput = StandardTypedV1.InferInput; - // (undocumented) - export type InferOutput = StandardTypedV1.InferOutput; -} - -// @public -interface StandardTypedV1 { - // (undocumented) - readonly '~standard': StandardTypedV1.Props; -} - -// @public (undocumented) -namespace StandardTypedV1 { - // (undocumented) - type InferInput = NonNullable['input']; - // (undocumented) - type InferOutput = NonNullable['output']; - // (undocumented) - interface Props { - // (undocumented) - readonly types?: Types | undefined; - // (undocumented) - readonly vendor: string; - // (undocumented) - readonly version: 1; - } - // (undocumented) - interface Types { - // (undocumented) - readonly input: Input; - // (undocumented) - readonly output: Output; - } -} - -// @public -export interface StartSSEOptions { - onresumptiontoken?: (token: string) => void; - replayMessageId?: string | number; - resumptionToken?: string; -} - -// @public -export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { - constructor(options: StaticPrivateKeyJwtProviderOptions); - // (undocumented) - addClientAuthentication: AddClientAuthentication; - // (undocumented) - clientInformation(): OAuthClientInformation; - // (undocumented) - get clientMetadata(): OAuthClientMetadata; - // (undocumented) - codeVerifier(): string; - // (undocumented) - prepareTokenRequest(scope?: string): URLSearchParams; - // (undocumented) - redirectToAuthorization(): void; - // (undocumented) - get redirectUrl(): undefined; - // (undocumented) - saveClientInformation(info: OAuthClientInformation): void; - // (undocumented) - saveCodeVerifier(): void; - // (undocumented) - saveTokens(tokens: OAuthTokens): void; - // (undocumented) - tokens(): OAuthTokens | undefined; -} - -// @public -export interface StaticPrivateKeyJwtProviderOptions { - clientId: string; - clientName?: string; - jwtBearerAssertion: string; - scope?: string; -} - -// @public -export class StreamableHTTPClientTransport implements Transport { - constructor(url: URL, opts?: StreamableHTTPClientTransportOptions); - // (undocumented) - close(): Promise; - finishAuth(authorizationCode: string): Promise; - // (undocumented) - onclose?: () => void; - // (undocumented) - onerror?: (error: Error) => void; - // (undocumented) - onmessage?: (message: JSONRPCMessage) => void; - // (undocumented) - get protocolVersion(): string | undefined; - resumeStream(lastEventId: string, options?: { - onresumptiontoken?: (token: string) => void; - }): Promise; - // (undocumented) - send(message: JSONRPCMessage | JSONRPCMessage[], options?: { - resumptionToken?: string; - onresumptiontoken?: (token: string) => void; - }): Promise; - // (undocumented) - get sessionId(): string | undefined; - // (undocumented) - setProtocolVersion(version: string): void; - // (undocumented) - start(): Promise; - terminateSession(): Promise; -} - -// @public -export type StreamableHTTPClientTransportOptions = { - authProvider?: AuthProvider | OAuthClientProvider; - requestInit?: RequestInit; - fetch?: FetchLike; - reconnectionOptions?: StreamableHTTPReconnectionOptions; - reconnectionScheduler?: ReconnectionScheduler; - sessionId?: string; - protocolVersion?: string; -}; - -// @public -export interface StreamableHTTPReconnectionOptions { - initialReconnectionDelay: number; - maxReconnectionDelay: number; - maxRetries: number; - reconnectionDelayGrowFactor: number; -} - -// @public (undocumented) -export type StringSchema = Infer; - -// @public -const StringSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -type StripSchemaSuffix = K extends `${infer N}Schema` ? N : never; - -// @public -type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; - -// @public (undocumented) -export type SubscribeRequest = Infer; - -// @public (undocumented) -export type SubscribeRequestParams = Infer; - -// @public (undocumented) -const SubscribeRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const SubscribeRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"resources/subscribe">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type Task = Infer; - -// @public @deprecated (undocumented) -export type TaskAugmentedRequestParams = Infer; - -// @public @deprecated -const TaskAugmentedRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type TaskCreationParams = Infer; - -// @public @deprecated -const TaskCreationParamsSchema: z.ZodObject<{ - ttl: z.ZodOptional; - pollInterval: z.ZodOptional; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type TaskMetadata = Infer; - -// @public @deprecated (undocumented) -const TaskMetadataSchema: z.ZodObject<{ - ttl: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -type TaskNotificationMethod = 'notifications/tasks/status'; - -// @public -type TaskRequestMethod = 'tasks/get' | 'tasks/result' | 'tasks/list' | 'tasks/cancel'; - -// @public @deprecated -const TaskSchema: z.ZodObject<{ - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type TaskStatus = Infer; - -// @public @deprecated (undocumented) -export type TaskStatusNotification = Infer; - -// @public @deprecated (undocumented) -export type TaskStatusNotificationParams = Infer; - -// @public @deprecated -const TaskStatusNotificationParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>; - -// @public @deprecated -const TaskStatusNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/tasks/status">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public @deprecated -const TaskStatusSchema: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; -}>; - -// @public (undocumented) -export type TextContent = Infer; - -// @public -const TextContentSchema: z.ZodObject<{ - type: z.ZodLiteral<"text">; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type TextResourceContents = Infer; - -// @public (undocumented) -const TextResourceContentsSchema: z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - text: z.ZodString; -}, z.core.$strip>; - -// @public (undocumented) -export type TitledMultiSelectEnumSchema = Infer; - -// @public -const TitledMultiSelectEnumSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type TitledSingleSelectEnumSchema = Infer; - -// @public -const TitledSingleSelectEnumSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type Tool = Infer; - -// @public (undocumented) -export type ToolAnnotations = Infer; - -// @public -const ToolAnnotationsSchema: z.ZodObject<{ - title: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ToolChoice = Infer; - -// @public -const ToolChoiceSchema: z.ZodObject<{ - mode: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type ToolExecution = Infer; - -// @public -const ToolExecutionSchema: z.ZodObject<{ - taskSupport: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type ToolListChangedNotification = Infer; - -// @public -const ToolListChangedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/tools/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type ToolResultContent = Infer; - -// @public -const ToolResultContentSchema: z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public -const ToolSchema: z.ZodObject<{ - description: z.ZodOptional; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ToolUseContent = Infer; - -// @public -const ToolUseContentSchema: z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public -export interface Transport { - close(): Promise; - onclose?: (() => void) | undefined; - onerror?: ((error: Error) => void) | undefined; - onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; - send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; - sessionId?: string | undefined; - setProtocolVersion?: ((version: string) => void) | undefined; - setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; - start(): Promise; -} - -// @public -export type TransportSendOptions = { - relatedRequestId?: RequestId | undefined; - resumptionToken?: string | undefined; - onresumptiontoken?: ((token: string) => void) | undefined; -}; - -// @public -interface UnauthorizedContext { - fetchFn: FetchLike; - response: Response; - serverUrl: URL; -} - -// @public (undocumented) -export class UnauthorizedError extends Error { - constructor(message?: string); -} - -// @public (undocumented) -export type UnsubscribeRequest = Infer; - -// @public (undocumented) -export type UnsubscribeRequestParams = Infer; - -// @public (undocumented) -const UnsubscribeRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const UnsubscribeRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"resources/unsubscribe">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public -export class UnsupportedProtocolVersionError extends ProtocolError { - constructor(data: UnsupportedProtocolVersionErrorData, message?: string); - get requested(): string; - get supported(): string[]; -} - -// @public -export interface UnsupportedProtocolVersionErrorData { - requested: string; - supported: string[]; -} - -// @public (undocumented) -export type UntitledMultiSelectEnumSchema = Infer; - -// @public -const UntitledMultiSelectEnumSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type UntitledSingleSelectEnumSchema = Infer; - -// @public -const UntitledSingleSelectEnumSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export class UriTemplate { - constructor(template: string); - // (undocumented) - expand(variables: Variables): string; - static isTemplate(str: string): boolean; - // (undocumented) - match(uri: string): Variables | null; - // (undocumented) - toString(): string; - // (undocumented) - get variableNames(): string[]; -} - -// @public -export class UrlElicitationRequiredError extends ProtocolError { - constructor(elicitations: ElicitRequestURLParams[], message?: string); - // (undocumented) - get elicitations(): ElicitRequestURLParams[]; -} - -// @public (undocumented) -export type Variables = Record; - -// @public -type WireOnlyResultKey = 'resultType'; - -// @public -export const applyMiddlewares: (...middleware: Middleware[]) => Middleware; - -// @public (undocumented) -export function assertCompleteRequestPrompt(request: CompleteRequest): asserts request is CompleteRequestPrompt; - -// @public (undocumented) -export function assertCompleteRequestResourceTemplate(request: CompleteRequest): asserts request is CompleteRequestResourceTemplate; - -// @public -export function auth(provider: OAuthClientProvider, options: { - serverUrl: string | URL; - authorizationCode?: string; - scope?: string; - resourceMetadataUrl?: URL; - fetchFn?: FetchLike; -}): Promise; - -// @public (undocumented) -const authSchemas: { - readonly OAuthClientInformationFullSchema: z.ZodObject<{ - redirect_uris: z.ZodArray; - token_endpoint_auth_method: z.ZodOptional; - grant_types: z.ZodOptional>; - response_types: z.ZodOptional>; - client_name: z.ZodOptional; - client_uri: z.ZodOptional; - logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - scope: z.ZodOptional; - contacts: z.ZodOptional>; - tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - policy_uri: z.ZodOptional; - jwks_uri: z.ZodOptional; - jwks: z.ZodOptional; - software_id: z.ZodOptional; - software_version: z.ZodOptional; - software_statement: z.ZodOptional; - client_id: z.ZodString; - client_secret: z.ZodOptional; - client_id_issued_at: z.ZodOptional; - client_secret_expires_at: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthClientInformationSchema: z.ZodObject<{ - client_id: z.ZodString; - client_secret: z.ZodOptional; - client_id_issued_at: z.ZodOptional; - client_secret_expires_at: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthClientMetadataSchema: z.ZodObject<{ - redirect_uris: z.ZodArray; - token_endpoint_auth_method: z.ZodOptional; - grant_types: z.ZodOptional>; - response_types: z.ZodOptional>; - client_name: z.ZodOptional; - client_uri: z.ZodOptional; - logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - scope: z.ZodOptional; - contacts: z.ZodOptional>; - tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - policy_uri: z.ZodOptional; - jwks_uri: z.ZodOptional; - jwks: z.ZodOptional; - software_id: z.ZodOptional; - software_version: z.ZodOptional; - software_statement: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthClientRegistrationErrorSchema: z.ZodObject<{ - error: z.ZodString; - error_description: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthErrorResponseSchema: z.ZodObject<{ - error: z.ZodString; - error_description: z.ZodOptional; - error_uri: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - revocation_endpoint: z.ZodOptional; - revocation_endpoint_auth_methods_supported: z.ZodOptional>; - revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - introspection_endpoint: z.ZodOptional; - introspection_endpoint_auth_methods_supported: z.ZodOptional>; - introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - code_challenge_methods_supported: z.ZodOptional>; - client_id_metadata_document_supported: z.ZodOptional; - }, z.core.$loose>; - readonly OAuthProtectedResourceMetadataSchema: z.ZodObject<{ - resource: z.ZodString; - authorization_servers: z.ZodOptional>; - jwks_uri: z.ZodOptional; - scopes_supported: z.ZodOptional>; - bearer_methods_supported: z.ZodOptional>; - resource_signing_alg_values_supported: z.ZodOptional>; - resource_name: z.ZodOptional; - resource_documentation: z.ZodOptional; - resource_policy_uri: z.ZodOptional; - resource_tos_uri: z.ZodOptional; - tls_client_certificate_bound_access_tokens: z.ZodOptional; - authorization_details_types_supported: z.ZodOptional>; - dpop_signing_alg_values_supported: z.ZodOptional>; - dpop_bound_access_tokens_required: z.ZodOptional; - }, z.core.$loose>; - readonly OAuthTokenRevocationRequestSchema: z.ZodObject<{ - token: z.ZodString; - token_type_hint: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthTokensSchema: z.ZodObject<{ - access_token: z.ZodString; - id_token: z.ZodOptional; - token_type: z.ZodString; - expires_in: z.ZodOptional>; - scope: z.ZodOptional; - refresh_token: z.ZodOptional; - }, z.core.$strip>; - readonly OpenIdProviderDiscoveryMetadataSchema: z.ZodObject<{ - code_challenge_methods_supported: z.ZodOptional>; - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - userinfo_endpoint: z.ZodOptional; - jwks_uri: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - acr_values_supported: z.ZodOptional>; - subject_types_supported: z.ZodArray; - id_token_signing_alg_values_supported: z.ZodArray; - id_token_encryption_alg_values_supported: z.ZodOptional>; - id_token_encryption_enc_values_supported: z.ZodOptional>; - userinfo_signing_alg_values_supported: z.ZodOptional>; - userinfo_encryption_alg_values_supported: z.ZodOptional>; - userinfo_encryption_enc_values_supported: z.ZodOptional>; - request_object_signing_alg_values_supported: z.ZodOptional>; - request_object_encryption_alg_values_supported: z.ZodOptional>; - request_object_encryption_enc_values_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - display_values_supported: z.ZodOptional>; - claim_types_supported: z.ZodOptional>; - claims_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - claims_locales_supported: z.ZodOptional>; - ui_locales_supported: z.ZodOptional>; - claims_parameter_supported: z.ZodOptional; - request_parameter_supported: z.ZodOptional; - request_uri_parameter_supported: z.ZodOptional; - require_request_uri_registration: z.ZodOptional; - op_policy_uri: z.ZodOptional; - op_tos_uri: z.ZodOptional; - client_id_metadata_document_supported: z.ZodOptional; - }, z.core.$strip>; - readonly OpenIdProviderMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - userinfo_endpoint: z.ZodOptional; - jwks_uri: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - acr_values_supported: z.ZodOptional>; - subject_types_supported: z.ZodArray; - id_token_signing_alg_values_supported: z.ZodArray; - id_token_encryption_alg_values_supported: z.ZodOptional>; - id_token_encryption_enc_values_supported: z.ZodOptional>; - userinfo_signing_alg_values_supported: z.ZodOptional>; - userinfo_encryption_alg_values_supported: z.ZodOptional>; - userinfo_encryption_enc_values_supported: z.ZodOptional>; - request_object_signing_alg_values_supported: z.ZodOptional>; - request_object_encryption_alg_values_supported: z.ZodOptional>; - request_object_encryption_enc_values_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - display_values_supported: z.ZodOptional>; - claim_types_supported: z.ZodOptional>; - claims_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - claims_locales_supported: z.ZodOptional>; - ui_locales_supported: z.ZodOptional>; - claims_parameter_supported: z.ZodOptional; - request_parameter_supported: z.ZodOptional; - request_uri_parameter_supported: z.ZodOptional; - require_request_uri_registration: z.ZodOptional; - op_policy_uri: z.ZodOptional; - op_tos_uri: z.ZodOptional; - client_id_metadata_document_supported: z.ZodOptional; - }, z.core.$loose>; -}; - -// @public -export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { - url: URL; - type: 'oauth' | 'oidc'; -}[]; - -// @public -export function checkResourceAllowed(input: { - requestedResource: URL | string; - configuredResource: URL | string; -}): boolean; - -// @public -export function createFetchWithInit(baseFetch?: FetchLike, baseInit?: RequestInit): FetchLike; - -// @public -export const createMiddleware: (handler: (next: FetchLike, input: string | URL, init?: RequestInit) => Promise) => Middleware; - -// @public -export function createPrivateKeyJwtAuth(options: { - issuer: string; - subject: string; - privateKey: string | Uint8Array | Record; - alg: string; - audience?: string | URL; - lifetimeSeconds?: number; - claims?: Record; -}): AddClientAuthentication; - -// @public (undocumented) -export function deserializeMessage(line: string): JSONRPCMessage; - -// @public -export function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise; - -// @public -export function discoverAuthorizationServerMetadata(authorizationServerUrl: string | URL, input?: { - fetchFn?: FetchLike; - protocolVersion?: string; -}): Promise; - -// @public @deprecated -export function discoverOAuthMetadata(issuer: string | URL, input?: { - authorizationServerUrl?: string | URL; - protocolVersion?: string; -}, fetchFn?: FetchLike): Promise; - -// @public -export function discoverOAuthProtectedResourceMetadata(serverUrl: string | URL, opts?: { - protocolVersion?: string; - resourceMetadataUrl?: string | URL; -}, fetchFn?: FetchLike): Promise; - -// @public -export function discoverOAuthServerInfo(serverUrl: string | URL, opts?: { - resourceMetadataUrl?: URL; - fetchFn?: FetchLike; -}): Promise; - -// @public -export function exchangeAuthorization(authorizationServerUrl: string | URL, input: { - metadata?: AuthorizationServerMetadata; - clientInformation: OAuthClientInformationMixed; - authorizationCode: string; - codeVerifier: string; - redirectUri: string | URL; - resource?: URL; - addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; - fetchFn?: FetchLike; -}): Promise; - -// @public -export function exchangeJwtAuthGrant(options: { - tokenEndpoint: string | URL; - jwtAuthGrant: string; - clientId: string; - clientSecret?: string; - authMethod?: ClientAuthMethod; - fetchFn?: FetchLike; -}): Promise<{ - access_token: string; - token_type: string; - expires_in?: number; - scope?: string; -}>; - -// @public @deprecated -export function extractResourceMetadataUrl(res: Response): URL | undefined; - -// @public -export function extractWWWAuthenticateParams(res: Response): { - resourceMetadataUrl?: URL; - scope?: string; - error?: string; -}; - -// @public -export function fetchToken(provider: OAuthClientProvider, authorizationServerUrl: string | URL, input?: { - metadata?: AuthorizationServerMetadata; - resource?: URL; - authorizationCode?: string; - scope?: string; - fetchFn?: FetchLike; -}): Promise; - -// @public (undocumented) -export function fromJsonSchema(schema: JsonSchemaType, validator?: jsonSchemaValidator): StandardSchemaWithJSON; - -// @public -export function getDisplayName(metadata: BaseMetadata | (BaseMetadata & { - annotations?: { - title?: string; - }; -})): string; - -// @public -export function getSupportedElicitationModes(capabilities: ClientCapabilities['elicitation']): { - supportsFormMode: boolean; - supportsUrlMode: boolean; -}; - -// @public -export const isCallToolResult: (value: unknown) => value is CallToolResult; - -// @public -export function isHttpsUrl(value?: string): boolean; - -// @public (undocumented) -export const isInitializeRequest: (value: unknown) => value is InitializeRequest; - -// @public (undocumented) -export const isInitializedNotification: (value: unknown) => value is InitializedNotification; - -// @public -export const isJSONRPCErrorResponse: (value: unknown) => value is JSONRPCErrorResponse; - -// @public (undocumented) -export const isJSONRPCNotification: (value: unknown) => value is JSONRPCNotification; - -// @public (undocumented) -export const isJSONRPCRequest: (value: unknown) => value is JSONRPCRequest; - -// @public -export const isJSONRPCResponse: (value: unknown) => value is JSONRPCResponse; - -// @public -export const isJSONRPCResultResponse: (value: unknown) => value is JSONRPCResultResponse; - -// @public -export const isSpecType: GuardRecord; - -// @public @deprecated -export const isTaskAugmentedRequestParams: (value: unknown) => value is TaskAugmentedRequestParams; - -// @public -export interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -export function parseErrorResponse(input: Response | string): Promise; - -// @public -export function parseJSONRPCMessage(value: unknown): JSONRPCMessage; - -// @public -export function prepareAuthorizationCodeRequest(authorizationCode: string, codeVerifier: string, redirectUri: string | URL): URLSearchParams; - -// @public -export function refreshAuthorization(authorizationServerUrl: string | URL, input: { - metadata?: AuthorizationServerMetadata; - clientInformation: OAuthClientInformationMixed; - refreshToken: string; - resource?: URL; - addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; - fetchFn?: FetchLike; -}): Promise; - -// @public -export function registerClient(authorizationServerUrl: string | URL, input: { - metadata?: AuthorizationServerMetadata; - clientMetadata: OAuthClientMetadata; - scope?: string; - fetchFn?: FetchLike; -}): Promise; - -// @public -export function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise; - -// @public -export function resourceUrlFromServerUrl(url: URL | string): URL; - -// @public (undocumented) -namespace schemas_d_exports { - export { AnnotationsSchema, AudioContentSchema, BaseMetadataSchema, BaseRequestParamsSchema, BlobResourceContentsSchema, BooleanSchemaSchema, CallToolRequestParamsSchema, CallToolRequestSchema, CallToolResultSchema, CancelTaskRequestSchema, CancelTaskResultSchema, CancelledNotificationParamsSchema, CancelledNotificationSchema, ClientCapabilitiesSchema, ClientNotificationSchema, ClientRequestSchema, ClientResultSchema, ClientTasksCapabilitySchema, CompatibilityCallToolResultSchema, CompleteRequestParamsSchema, CompleteRequestSchema, CompleteResultSchema, ContentBlockSchema, CreateMessageRequestParamsSchema, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, CreateTaskResultSchema, CursorSchema, DiscoverRequestSchema, DiscoverResultSchema, ElicitRequestFormParamsSchema, ElicitRequestParamsSchema, ElicitRequestSchema, ElicitRequestURLParamsSchema, ElicitResultSchema, ElicitationCompleteNotificationParamsSchema, ElicitationCompleteNotificationSchema, EmbeddedResourceSchema, EmptyResultSchema, EnumSchemaSchema, GetPromptRequestParamsSchema, GetPromptRequestSchema, GetPromptResultSchema, GetTaskPayloadRequestSchema, GetTaskPayloadResultSchema, GetTaskRequestSchema, GetTaskResultSchema, IconSchema, IconsSchema, ImageContentSchema, ImplementationSchema, InitializeRequestParamsSchema, InitializeRequestSchema, InitializeResultSchema, InitializedNotificationSchema, JSONArraySchema, JSONObjectSchema, JSONRPCErrorResponseSchema, JSONRPCMessageSchema, JSONRPCNotificationSchema, JSONRPCRequestSchema, JSONRPCResponseSchema, JSONRPCResultResponseSchema, JSONValueSchema, LegacyTitledEnumSchemaSchema, ListChangedOptionsBaseSchema, ListPromptsRequestSchema, ListPromptsResultSchema, ListResourceTemplatesRequestSchema, ListResourceTemplatesResultSchema, ListResourcesRequestSchema, ListResourcesResultSchema, ListRootsRequestSchema, ListRootsResultSchema, ListTasksRequestSchema, ListTasksResultSchema, ListToolsRequestSchema, ListToolsResultSchema, LoggingLevelSchema, LoggingMessageNotificationParamsSchema, LoggingMessageNotificationSchema, ModelHintSchema, ModelPreferencesSchema, MultiSelectEnumSchemaSchema, NotificationSchema, NotificationsParamsSchema, NumberSchemaSchema, PaginatedRequestParamsSchema, PaginatedRequestSchema, PaginatedResultSchema, PingRequestSchema, PrimitiveSchemaDefinitionSchema, ProgressNotificationParamsSchema, ProgressNotificationSchema, ProgressSchema, ProgressTokenSchema, PromptArgumentSchema, PromptListChangedNotificationSchema, PromptMessageSchema, PromptReferenceSchema, PromptSchema, ReadResourceRequestParamsSchema, ReadResourceRequestSchema, ReadResourceResultSchema, RelatedTaskMetadataSchema, RequestIdSchema, RequestMetaEnvelopeSchema, RequestMetaSchema, RequestSchema, ResourceContentsSchema, ResourceLinkSchema, ResourceListChangedNotificationSchema, ResourceRequestParamsSchema, ResourceSchema, ResourceTemplateReferenceSchema, ResourceTemplateSchema, ResourceUpdatedNotificationParamsSchema, ResourceUpdatedNotificationSchema, ResultSchema, RoleSchema, RootSchema, RootsListChangedNotificationSchema, SamplingContentSchema, SamplingMessageContentBlockSchema, SamplingMessageSchema, ServerCapabilitiesSchema, ServerNotificationSchema, ServerRequestSchema, ServerResultSchema, ServerTasksCapabilitySchema, SetLevelRequestParamsSchema, SetLevelRequestSchema, SingleSelectEnumSchemaSchema, StringSchemaSchema, SubscribeRequestParamsSchema, SubscribeRequestSchema, TaskAugmentedRequestParamsSchema, TaskCreationParamsSchema, TaskMetadataSchema, TaskSchema, TaskStatusNotificationParamsSchema, TaskStatusNotificationSchema, TaskStatusSchema, TextContentSchema, TextResourceContentsSchema, TitledMultiSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema, ToolAnnotationsSchema, ToolChoiceSchema, ToolExecutionSchema, ToolListChangedNotificationSchema, ToolResultContentSchema, ToolSchema, ToolUseContentSchema, UnsubscribeRequestParamsSchema, UnsubscribeRequestSchema, UntitledMultiSelectEnumSchemaSchema, UntitledSingleSelectEnumSchemaSchema, getNotificationSchema, getRequestSchema, getResultSchema }; -} - -// @public -export function selectClientAuthMethod(clientInformation: OAuthClientInformationMixed, supportedMethods: string[]): ClientAuthMethod; - -// @public (undocumented) -export function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise; - -// @public (undocumented) -export function serializeMessage(message: JSONRPCMessage): string; - -// @public -export const specTypeSchemas: SchemaRecord; - -// @public -export function startAuthorization(authorizationServerUrl: string | URL, input: { - metadata?: AuthorizationServerMetadata; - clientInformation: OAuthClientInformationMixed; - redirectUrl: string | URL; - scope?: string; - state?: string; - resource?: URL; -}): Promise<{ - authorizationUrl: URL; - codeVerifier: string; -}>; - -// @public -export function validateClientMetadataUrl(url: string | undefined): void; - -// @public -export const withLogging: (options?: LoggingOptions) => Middleware; - -// @public -export const withOAuth: (provider: OAuthClientProvider, baseUrl?: string | URL) => Middleware; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/client/etc/client.shims-browser.api.md b/packages/client/etc/client.shims-browser.api.md deleted file mode 100644 index 201e6a97d4..0000000000 --- a/packages/client/etc/client.shims-browser.api.md +++ /dev/null @@ -1,47 +0,0 @@ -## API Report File for "@modelcontextprotocol/client" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { JSONSchema } from 'json-schema-typed'; - -// @public -export const CORS_IS_POSSIBLE = true; - -// @public -type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; - -// @public -export class DefaultJsonSchemaValidator implements jsonSchemaValidator { - constructor(options?: { - shortcircuit?: boolean; - draft?: CfWorkerSchemaDraft; - }); - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -type JsonSchemaType = JSONSchema.Interface; - -// @public -type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public -interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/client/etc/client.shims-workerd.api.md b/packages/client/etc/client.shims-workerd.api.md deleted file mode 100644 index f57cf3690f..0000000000 --- a/packages/client/etc/client.shims-workerd.api.md +++ /dev/null @@ -1,47 +0,0 @@ -## API Report File for "@modelcontextprotocol/client" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { JSONSchema } from 'json-schema-typed'; - -// @public -export const CORS_IS_POSSIBLE = false; - -// @public -type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; - -// @public -export class DefaultJsonSchemaValidator implements jsonSchemaValidator { - constructor(options?: { - shortcircuit?: boolean; - draft?: CfWorkerSchemaDraft; - }); - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -type JsonSchemaType = JSONSchema.Interface; - -// @public -type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public -interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/client/etc/client.shims.api.md b/packages/client/etc/client.shims.api.md deleted file mode 100644 index 512ebf6bd8..0000000000 --- a/packages/client/etc/client.shims.api.md +++ /dev/null @@ -1,60 +0,0 @@ -## API Report File for "@modelcontextprotocol/client" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { JSONSchema } from 'json-schema-typed'; - -// @public -interface AjvLike { - // (undocumented) - compile: (schema: unknown) => AjvValidateFunction; - // (undocumented) - errorsText: (errors?: any) => string; - // (undocumented) - getSchema: (keyRef: string) => AjvValidateFunction | undefined; -} - -// @public (undocumented) -interface AjvValidateFunction { - // (undocumented) - (input: unknown): boolean; - // (undocumented) - errors?: any; -} - -// @public -export const CORS_IS_POSSIBLE = false; - -// @public -export class DefaultJsonSchemaValidator implements jsonSchemaValidator { - constructor(ajv?: AjvLike); - // (undocumented) - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -type JsonSchemaType = JSONSchema.Interface; - -// @public -type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public -interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/client/etc/client.stdio.api.md b/packages/client/etc/client.stdio.api.md deleted file mode 100644 index b3a5f16b34..0000000000 --- a/packages/client/etc/client.stdio.api.md +++ /dev/null @@ -1,191 +0,0 @@ -## API Report File for "@modelcontextprotocol/client" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { IOType } from 'node:child_process'; -import { Stream } from 'node:stream'; -import * as z from 'zod/v4'; - -// @public -interface AuthInfo { - clientId: string; - expiresAt?: number; - extra?: Record; - resource?: URL; - scopes: string[]; - token: string; -} - -// @public -export const DEFAULT_INHERITED_ENV_VARS: string[]; - -// @public (undocumented) -type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; - -// @public (undocumented) -type Infer = Flatten>; - -// @public (undocumented) -type JSONRPCErrorResponse = Infer; - -// @public -const JSONRPCErrorResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; - -// @public (undocumented) -type JSONRPCNotification = Infer; - -// @public -const JSONRPCNotificationSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCRequest = Infer; - -// @public -const JSONRPCRequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCResultResponse = Omit, 'result'> & { - result: Result; -}; - -// @public -const JSONRPCResultResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>; - -// @public -interface MessageExtraInfo { - authInfo?: AuthInfo; - closeSSEStream?: () => void; - closeStandaloneSSEStream?: () => void; - request?: globalThis.Request; -} - -// @public (undocumented) -type Primitive = string | number | boolean | bigint | null | undefined; - -// @public (undocumented) -type RequestId = Infer; - -// @public -const RequestIdSchema: z.ZodUnion; - -// @public (undocumented) -type Result = StripWireOnly>; - -// @public (undocumented) -const ResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$loose>; - -// @public -export class StdioClientTransport implements Transport { - constructor(server: StdioServerParameters); - // (undocumented) - close(): Promise; - // (undocumented) - onclose?: () => void; - // (undocumented) - onerror?: (error: Error) => void; - // (undocumented) - onmessage?: (message: JSONRPCMessage) => void; - get pid(): number | null; - // (undocumented) - send(message: JSONRPCMessage): Promise; - start(): Promise; - get stderr(): Stream | null; -} - -// @public (undocumented) -export type StdioServerParameters = { - command: string; - args?: string[]; - env?: Record; - stderr?: IOType | Stream | number; - cwd?: string; - maxBufferSize?: number; -}; - -// @public -type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; - -// @public -interface Transport { - close(): Promise; - onclose?: (() => void) | undefined; - onerror?: ((error: Error) => void) | undefined; - onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; - send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; - sessionId?: string | undefined; - setProtocolVersion?: ((version: string) => void) | undefined; - setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; - start(): Promise; -} - -// @public -type TransportSendOptions = { - relatedRequestId?: RequestId | undefined; - resumptionToken?: string | undefined; - onresumptiontoken?: ((token: string) => void) | undefined; -}; - -// @public -type WireOnlyResultKey = 'resultType'; - -// @public -export function getDefaultEnvironment(): Record; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/client/etc/client.validators-ajv.api.md b/packages/client/etc/client.validators-ajv.api.md deleted file mode 100644 index c7759ab39c..0000000000 --- a/packages/client/etc/client.validators-ajv.api.md +++ /dev/null @@ -1,1572 +0,0 @@ -## API Report File for "@modelcontextprotocol/client" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { JSONSchema } from 'json-schema-typed'; - -// @public (undocumented) -type AddedFormat = true | RegExp | FormatValidator | FormatDefinition | FormatDefinition | AsyncFormatDefinition | AsyncFormatDefinition; - -// @public (undocumented) -type AddedKeywordDefinition = KeywordDefinition & { - type: JSONType[]; - schemaType: JSONType[]; -}; - -// @public (undocumented) -export class Ajv extends Ajv$2 { - // (undocumented) - _addDefaultMetaSchema(): void; - // (undocumented) - _addVocabularies(): void; - // (undocumented) - defaultMeta(): string | AnySchemaObject | undefined; -} - -// @public (undocumented) -class Ajv$2 { - // (undocumented) - $dataMetaSchema(metaSchema: AnySchemaObject, keywordsJsonPointers: string[]): AnySchemaObject; - constructor(opts?: Options); - // (undocumented) - _addDefaultMetaSchema(): void; - // (undocumented) - addFormat(name: string, format: Format): Ajv$2; - // (undocumented) - addKeyword(kwdOrDef: string | KeywordDefinition, def?: KeywordDefinition): Ajv$2; - // (undocumented) - addMetaSchema(schema: AnySchemaObject, key?: string, - // schema key - _validateSchema?: boolean | "log"): Ajv$2; - // (undocumented) - addSchema(schema: AnySchema | AnySchema[], - // If array is passed, `key` will be ignored - key?: string, - // Optional schema key. Can be passed to `validate` method instead of schema object or id/ref. One schema per instance can have empty `id` and `key`. - _meta?: boolean, - // true if schema is a meta-schema. Used internally, addMetaSchema should be used instead. - _validateSchema?: boolean | "log"): Ajv$2; - // (undocumented) - _addSchema(schema: AnySchema, meta?: boolean, baseId?: string, validateSchema?: boolean | "log", addSchema?: boolean): SchemaEnv; - // (undocumented) - _addVocabularies(): void; - // (undocumented) - addVocabulary(definitions: Vocabulary): Ajv$2; - // (undocumented) - readonly _compilations: Set; - // (undocumented) - compile(schema: Schema | JSONSchemaType, _meta?: boolean): ValidateFunction; - // (undocumented) - compile(schema: JTDSchemaType, _meta?: boolean): ValidateFunction; - // (undocumented) - compile(schema: T, _meta?: boolean): ValidateFunction>; - // (undocumented) - compile(schema: AsyncSchema, _meta?: boolean): AsyncValidateFunction; - // (undocumented) - compile(schema: AnySchema, _meta?: boolean): AnyValidateFunction; - // (undocumented) - compileAsync(schema: SchemaObject | JSONSchemaType, _meta?: boolean): Promise>; - // (undocumented) - compileAsync(schema: JTDSchemaType, _meta?: boolean): Promise>; - // (undocumented) - compileAsync(schema: AsyncSchema, meta?: boolean): Promise>; - // (undocumented) - compileAsync(schema: AnySchemaObject, meta?: boolean): Promise>; - // (undocumented) - defaultMeta(): string | AnySchemaObject | undefined; - // (undocumented) - errors?: ErrorObject[] | null; - // (undocumented) - errorsText(errors?: ErrorObject[] | null | undefined, - // optional array of validation errors - input?: ErrorsTextOptions): string; - // (undocumented) - readonly formats: { [Name in string]?: AddedFormat }; - // (undocumented) - getKeyword(keyword: string): AddedKeywordDefinition | boolean; - // (undocumented) - getSchema(keyRef: string): AnyValidateFunction | undefined; - // (undocumented) - logger: Logger; - // (undocumented) - static MissingRefError: typeof MissingRefError; - // (undocumented) - opts: InstanceOptions; - // (undocumented) - readonly refs: { [Ref in string]?: SchemaEnv | string }; - // (undocumented) - removeKeyword(keyword: string): Ajv$2; - // (undocumented) - removeSchema(schemaKeyRef?: AnySchema | string | RegExp): Ajv$2; - // (undocumented) - readonly RULES: ValidationRules; - // (undocumented) - readonly schemas: { [Key in string]?: SchemaEnv }; - // (undocumented) - readonly scope: ValueScope; - // (undocumented) - validate(schema: Schema | string, data: unknown): boolean; - // (undocumented) - validate(schemaKeyRef: AnySchema | string, data: unknown): boolean | Promise; - // (undocumented) - validate(schema: Schema | JSONSchemaType | string, data: unknown): data is T; - // (undocumented) - validate(schema: JTDSchemaType, data: unknown): data is T; - // (undocumented) - validate(schema: T, data: unknown): data is JTDDataType; - // (undocumented) - validate(schema: AsyncSchema, data: unknown | T): Promise; - // (undocumented) - validate(schemaKeyRef: AnySchema | string, data: unknown): data is T | Promise; - // (undocumented) - validateSchema(schema: AnySchema, throwOrLogError?: boolean): boolean | Promise; - // (undocumented) - static ValidationError: typeof ValidationError; -} - -// @public -export class AjvJsonSchemaValidator implements jsonSchemaValidator { - constructor(ajv?: AjvLike); - // (undocumented) - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -interface AjvLike { - // (undocumented) - compile: (schema: unknown) => AjvValidateFunction; - // (undocumented) - errorsText: (errors?: any) => string; - // (undocumented) - getSchema: (keyRef: string) => AjvValidateFunction | undefined; -} - -// @public (undocumented) -interface AjvValidateFunction { - // (undocumented) - (input: unknown): boolean; - // (undocumented) - errors?: any; -} - -// @public (undocumented) -type AnySchema = Schema | AsyncSchema; - -// @public (undocumented) -type AnySchemaObject = SchemaObject | AsyncSchema; - -// @public (undocumented) -type AnyValidateFunction = ValidateFunction | AsyncValidateFunction; - -// @public (undocumented) -interface AsyncFormatDefinition { - // (undocumented) - async: true; - // (undocumented) - compare?: FormatCompare; - // (undocumented) - type?: T extends string ? "string" | undefined : "number"; - // (undocumented) - validate: AsyncFormatValidator; -} - -// @public (undocumented) -type AsyncFormatValidator = (data: T) => Promise; - -// @public (undocumented) -interface AsyncSchema extends _SchemaObject { - // (undocumented) - $async: true; -} - -// @public (undocumented) -interface AsyncValidateFunction extends ValidateFunction { - // (undocumented) - $async: true; - // (undocumented) - (...args: Parameters>): Promise; -} - -// @public (undocumented) -type Block = Code | (() => void); - -// @public (undocumented) -type Code = _Code | Name; - -// @public (undocumented) -class CodeGen { - constructor(extScope: ValueScope, opts?: CodeGenOptions); - // (undocumented) - add(lhs: Code, rhs: SafeExpr): CodeGen; - // (undocumented) - assign(lhs: Code, rhs: SafeExpr, sideEffects?: boolean): CodeGen; - // (undocumented) - block(body?: Block, nodeCount?: number): CodeGen; - // (undocumented) - break(label?: Code): CodeGen; - // (undocumented) - code(c: Block | SafeExpr): CodeGen; - // (undocumented) - const(nameOrPrefix: Name | string, rhs: SafeExpr, _constant?: boolean): Name; - // (undocumented) - else(): CodeGen; - // (undocumented) - elseIf(condition: Code | boolean): CodeGen; - // (undocumented) - endBlock(nodeCount?: number): CodeGen; - // (undocumented) - endFor(): CodeGen; - // (undocumented) - endFunc(): CodeGen; - // (undocumented) - endIf(): CodeGen; - // (undocumented) - readonly _extScope: ValueScope; - // (undocumented) - for(iteration: Code, forBody?: Block): CodeGen; - // (undocumented) - forIn(nameOrPrefix: Name | string, obj: Code, forBody: (item: Name) => void, varKind?: Code): CodeGen; - // (undocumented) - forOf(nameOrPrefix: Name | string, iterable: Code, forBody: (item: Name) => void, varKind?: Code): CodeGen; - // (undocumented) - forRange(nameOrPrefix: Name | string, from: SafeExpr, to: SafeExpr, forBody: (index: Name) => void, varKind?: Code): CodeGen; - // (undocumented) - func(name: Name, args?: Code, async?: boolean, funcBody?: Block): CodeGen; - // (undocumented) - getScopeValue(prefix: string, keyOrRef: unknown): ValueScopeName | undefined; - // (undocumented) - if(condition: Code | boolean, thenBody?: Block, elseBody?: Block): CodeGen; - // (undocumented) - label(label: Name): CodeGen; - // (undocumented) - let(nameOrPrefix: Name | string, rhs?: SafeExpr, _constant?: boolean): Name; - // (undocumented) - name(prefix: string): Name; - // (undocumented) - object(...keyValues: [Name | string, SafeExpr | string][]): _Code; - // (undocumented) - optimize(n?: number): void; - // (undocumented) - return(value: Block | SafeExpr): CodeGen; - // (undocumented) - readonly _scope: Scope; - // (undocumented) - scopeCode(): Code; - // (undocumented) - scopeName(prefix: string): ValueScopeName; - // (undocumented) - scopeRefs(scopeName: Name): Code; - // (undocumented) - scopeValue(prefixOrName: ValueScopeName | string, value: NameValue): Name; - // (undocumented) - throw(error: Code): CodeGen; - // (undocumented) - toString(): string; - // (undocumented) - try(tryBody: Block, catchCode?: (e: Name) => void, finallyCode?: Block): CodeGen; - // (undocumented) - readonly _values: ScopeValueSets; - // (undocumented) - var(nameOrPrefix: Name | string, rhs?: SafeExpr, _constant?: boolean): Name; -} - -// @public (undocumented) -interface CodeGenOptions { - // (undocumented) - es5?: boolean; - // (undocumented) - lines?: boolean; - // (undocumented) - ownProperties?: boolean; -} - -// @public (undocumented) -type CodeItem = Name | string | number | boolean | null; - -// @public (undocumented) -interface CodeKeywordDefinition extends _KeywordDef { - // (undocumented) - code: (cxt: KeywordCxt, ruleType?: string) => void; - // (undocumented) - trackErrors?: boolean; -} - -// @public (undocumented) -interface CodeOptions { - // (undocumented) - es5?: boolean; - // (undocumented) - esm?: boolean; - // (undocumented) - formats?: Code; - // (undocumented) - lines?: boolean; - // (undocumented) - optimize?: boolean | number; - // (undocumented) - process?: (code: string, schema?: SchemaEnv) => string; - // (undocumented) - regExp?: RegExpEngine; - // (undocumented) - source?: boolean; -} - -// @public (undocumented) -type CompileKeywordFunc = (schema: any, parentSchema: AnySchemaObject, it: SchemaObjCxt) => DataValidateFunction; - -// @public (undocumented) -interface CurrentOptions { - // (undocumented) - $comment?: true | ((comment: string, schemaPath?: string, rootSchema?: AnySchemaObject) => unknown); - // (undocumented) - $data?: boolean; - // (undocumented) - addUsedSchema?: boolean; - // (undocumented) - allErrors?: boolean; - // (undocumented) - allowDate?: boolean; - // (undocumented) - allowMatchingProperties?: boolean; - // (undocumented) - allowUnionTypes?: boolean; - // (undocumented) - code?: CodeOptions; - // (undocumented) - coerceTypes?: boolean | "array"; - // (undocumented) - defaultMeta?: string | AnySchemaObject; - // (undocumented) - discriminator?: boolean; - // (undocumented) - dynamicRef?: boolean; - // (undocumented) - formats?: { [Name in string]?: Format }; - // (undocumented) - inlineRefs?: boolean | number; - // (undocumented) - int32range?: boolean; - // (undocumented) - jtd?: boolean; - // (undocumented) - keywords?: Vocabulary; - // (undocumented) - loadSchema?: (uri: string) => Promise; - // (undocumented) - logger?: Logger | false; - // (undocumented) - loopEnum?: number; - // (undocumented) - loopRequired?: number; - // (undocumented) - messages?: boolean; - // (undocumented) - meta?: SchemaObject | boolean; - // (undocumented) - multipleOfPrecision?: number; - // (undocumented) - next?: boolean; - // (undocumented) - ownProperties?: boolean; - // (undocumented) - parseDate?: boolean; - // (undocumented) - passContext?: boolean; - // (undocumented) - removeAdditional?: boolean | "all" | "failing"; - // (undocumented) - schemaId?: "id" | "$id"; - // (undocumented) - schemas?: AnySchema[] | { [Key in string]?: AnySchema }; - // (undocumented) - specialNumbers?: "fast" | "null"; - // (undocumented) - strict?: boolean | "log"; - // (undocumented) - strictNumbers?: boolean | "log"; - // (undocumented) - strictRequired?: boolean | "log"; - // (undocumented) - strictSchema?: boolean | "log"; - // (undocumented) - strictTuples?: boolean | "log"; - // (undocumented) - strictTypes?: boolean | "log"; - // (undocumented) - timestamp?: "string" | "date"; - // (undocumented) - unevaluated?: boolean; - // (undocumented) - unicodeRegExp?: boolean; - // (undocumented) - uriResolver?: UriResolver; - // (undocumented) - useDefaults?: boolean | "empty"; - // (undocumented) - validateFormats?: boolean; - // (undocumented) - validateSchema?: boolean | "log"; - // (undocumented) - verbose?: boolean; -} - -// @public (undocumented) -interface DataValidateFunction { - // (undocumented) - (...args: Parameters): boolean | Promise; - // (undocumented) - errors?: Partial[]; -} - -// @public (undocumented) -interface DataValidationCxt { - // (undocumented) - dynamicAnchors: { [Ref in string]?: ValidateFunction }; - // (undocumented) - instancePath: string; - // (undocumented) - parentData: { [K in T]: any }; - // (undocumented) - parentDataProperty: T; - // (undocumented) - rootData: Record | any[]; -} - -// @public (undocumented) -interface DeprecatedOptions { - // @deprecated (undocumented) - ignoreKeywordsWithRef?: boolean; - // @deprecated (undocumented) - jsPropertySyntax?: boolean; - // @deprecated (undocumented) - unicode?: boolean; -} - -// @public -type EnumString = [T] extends [never] ? null : T extends string ? string extends T ? null : T : null; - -// @public (undocumented) -interface ErrorObject, S = unknown> { - // (undocumented) - data?: unknown; - // (undocumented) - instancePath: string; - // (undocumented) - keyword: K; - // (undocumented) - message?: string; - // (undocumented) - params: P; - // (undocumented) - parentSchema?: AnySchemaObject; - // (undocumented) - propertyName?: string; - // (undocumented) - schema?: S; - // (undocumented) - schemaPath: string; -} - -// @public (undocumented) -interface ErrorPaths { - // (undocumented) - instancePath?: Code; - // (undocumented) - parentSchema?: boolean; - // (undocumented) - schemaPath?: string; -} - -// @public (undocumented) -interface ErrorsTextOptions { - // (undocumented) - dataVar?: string; - // (undocumented) - separator?: string; -} - -// @public (undocumented) -interface Evaluated { - // (undocumented) - dynamicItems: boolean; - // (undocumented) - dynamicProps: boolean; - // (undocumented) - items?: EvaluatedItems; - // (undocumented) - props?: EvaluatedProperties; -} - -// @public (undocumented) -type EvaluatedItems = number | true; - -// @public (undocumented) -type EvaluatedProperties = { [K in string]?: true } | true; - -// @public (undocumented) -type Format = AddedFormat | string; - -// @public (undocumented) -type FormatCompare = (data1: T, data2: T) => number | undefined; - -// @public (undocumented) -interface FormatDefinition { - // (undocumented) - async?: false | undefined; - // (undocumented) - compare?: FormatCompare; - // (undocumented) - type?: T extends string ? "string" | undefined : "number"; - // (undocumented) - validate: FormatValidator | (T extends string ? string | RegExp : never); -} - -// @public (undocumented) -type FormatMode = "fast" | "full"; - -// @public (undocumented) -type FormatName = "date" | "time" | "date-time" | "iso-time" | "iso-date-time" | "duration" | "uri" | "uri-reference" | "uri-template" | "url" | "email" | "hostname" | "ipv4" | "ipv6" | "regex" | "uuid" | "json-pointer" | "json-pointer-uri-fragment" | "relative-json-pointer" | "byte" | "int32" | "int64" | "float" | "double" | "password" | "binary"; - -// @public (undocumented) -interface FormatOptions { - // (undocumented) - formats?: FormatName[]; - // (undocumented) - keywords?: boolean; - // (undocumented) - mode?: FormatMode; -} - -// @public (undocumented) -type FormatValidator = (data: T) => boolean; - -// @public (undocumented) -interface FormatsPlugin extends Plugin_2 { - // (undocumented) - get: (format: FormatName, mode?: FormatMode) => Format; -} - -// @public (undocumented) -type FormatsPluginOptions = FormatName[] | FormatOptions; - -// @public (undocumented) -interface FuncKeywordDefinition extends _KeywordDef { - // (undocumented) - async?: boolean; - // (undocumented) - compile?: CompileKeywordFunc; - // (undocumented) - errors?: boolean | "full"; - // (undocumented) - modifying?: boolean; - // (undocumented) - schema?: boolean; - // (undocumented) - valid?: boolean; - // (undocumented) - validate?: SchemaValidateFunction | DataValidateFunction; -} - -// @public (undocumented) -interface InstanceCodeOptions extends CodeOptions { - // (undocumented) - optimize: number; - // (undocumented) - regExp: RegExpEngine; -} - -// @public (undocumented) -type InstanceOptions = Options & RequiredInstanceOptions; - -// @public -type IsElements = false extends IsUnion ? [T] extends [readonly unknown[]] ? undefined extends T[0.5] ? false : true : false : false; - -// @public -type IsEmptyRecord = [T] extends [Record] ? [T] extends [never] ? false : true : false; - -// @public -type IsEnum = null extends EnumString ? false : true; - -// @public -type IsRecord = Union extends IsUnion ? null extends EnumString ? false : true : false; - -// @public (undocumented) -type IsUnion = IsUnion_; - -// @public -type IsUnion_ = false extends (T extends unknown ? ([U] extends [T] ? false : true) : never) ? false : true; - -// @public -type IsValues = false extends IsUnion ? TypeEquality : false; - -// @public (undocumented) -type JSONSchemaType = StrictNullChecksWrapper<"JSONSchemaType", UncheckedJSONSchemaType>; - -// @public (undocumented) -type JSONType = (typeof _jsonTypes)[number]; - -// @public (undocumented) -type JSONType$2 = IsPartial extends true ? T | undefined : T; - -// @public (undocumented) -type JTDDataDef> = -// ref -(S extends { - ref: string; -} ? D extends { [K in S["ref"]]: infer V } ? JTDDataDef : never : S extends { - type: NumberType; -} ? number : S extends { - type: "boolean"; -} ? boolean : S extends { - type: "string"; -} ? string : S extends { - type: "timestamp"; -} ? string | Date : S extends { - enum: readonly (infer E)[]; -} ? string extends E ? never : [E] extends [string] ? E : never : S extends { - elements: infer E; -} ? JTDDataDef[] : S extends { - properties: Record; - optionalProperties?: Record; - additionalProperties?: boolean; -} ? { -readonly [K in keyof S["properties"]]-?: JTDDataDef } & { -readonly [K in keyof S["optionalProperties"]]+?: JTDDataDef } & ([S["additionalProperties"]] extends [true] ? Record : unknown) : S extends { - properties?: Record; - optionalProperties: Record; - additionalProperties?: boolean; -} ? { -readonly [K in keyof S["properties"]]-?: JTDDataDef } & { -readonly [K in keyof S["optionalProperties"]]+?: JTDDataDef } & ([S["additionalProperties"]] extends [true] ? Record : unknown) : S extends { - values: infer V; -} ? Record> : S extends { - discriminator: infer M; - mapping: Record; -} ? [M] extends [string] ? { [K in keyof S["mapping"]]: JTDDataDef & { [KM in M]: K } }[keyof S["mapping"]] : never : unknown) | (S extends { - nullable: true; -} ? null : never); - -// @public (undocumented) -type JTDDataType = S extends { - definitions: Record; -} ? JTDDataDef : JTDDataDef>; - -// @public -type JTDSchemaType = Record> = ( -// refs - where null wasn't specified, must match exactly -(null extends EnumString ? never : ({ [K in keyof D]: [T] extends [D[K]] ? { - ref: K; - } : never }[keyof D] & { - nullable?: false; -}) | (null extends T ? { [K in keyof D]: [Exclude] extends [Exclude] ? { - ref: K; - } : never }[keyof D] & { - nullable: true; -} : never)) | (unknown extends T ? { - nullable?: boolean; -} : never) | ((true extends NullTypeEquality ? { - type: NumberType; -} : true extends NullTypeEquality ? { - type: "boolean"; -} : true extends NullTypeEquality ? { - type: StringType; -} : true extends NullTypeEquality ? { - type: "timestamp"; -} : true extends IsEnum> ? { - enum: EnumString>[]; -} : true extends IsElements> ? T extends readonly (infer E)[] ? { - elements: JTDSchemaType; -} : never : true extends IsEmptyRecord> ? { - properties: Record; - optionalProperties?: Record; -} | { - optionalProperties: Record; -} : true extends IsValues> ? T extends Record ? { - values: JTDSchemaType; -} : never : true extends IsRecord, false> ? ([RequiredKeys>] extends [never] ? { - properties?: Record; -} : { - properties: { [K in RequiredKeys]: JTDSchemaType }; -}) & ([OptionalKeys>] extends [never] ? { - optionalProperties?: Record; -} : { - optionalProperties: { [K in OptionalKeys]: JTDSchemaType, D> }; -}) & { - additionalProperties?: boolean; -} : true extends IsRecord, true> ? { [K in keyof Exclude]-?: Exclude[K] extends string ? { - discriminator: K; - mapping: { [M in Exclude[K]]: JTDSchemaType ? T : never, K>, D> }; - } : never }[keyof Exclude] : never) & (null extends T ? { - nullable: true; -} : { - nullable?: false; -}))) & { - metadata?: Record; - definitions?: { [K in keyof D]: JTDSchemaType }; -}; - -// @public -type JsonSchemaType = JSONSchema.Interface; - -// @public -type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public (undocumented) -class KeywordCxt implements KeywordErrorCxt { - // (undocumented) - readonly $data?: string | false; - // (undocumented) - $dataError(): void; - constructor(it: SchemaObjCxt, def: AddedKeywordDefinition, keyword: string); - // (undocumented) - readonly allErrors?: boolean; - // (undocumented) - block$data(valid: Name, codeBlock: () => void, $dataValid?: Code): void; - // (undocumented) - check$data(valid?: Name, $dataValid?: Code): void; - // (undocumented) - readonly data: Name; - // (undocumented) - readonly def: AddedKeywordDefinition; - // (undocumented) - error(append?: boolean, errorParams?: KeywordCxtParams, errorPaths?: ErrorPaths): void; - // (undocumented) - readonly errsCount?: Name; - // (undocumented) - fail$data(condition: Code): void; - // (undocumented) - fail(condition?: Code): void; - // (undocumented) - failResult(condition: Code, successAction?: () => void, failAction?: () => void): void; - // (undocumented) - readonly gen: CodeGen; - // (undocumented) - invalid$data(): Code; - // (undocumented) - readonly it: SchemaObjCxt; - // (undocumented) - readonly keyword: string; - // (undocumented) - mergeEvaluated(schemaCxt: SchemaCxt, toName?: typeof Name): void; - // (undocumented) - mergeValidEvaluated(schemaCxt: SchemaCxt, valid: Name): boolean | void; - // (undocumented) - ok(cond: Code | boolean): void; - // (undocumented) - params: KeywordCxtParams; - // (undocumented) - readonly parentSchema: AnySchemaObject; - // (undocumented) - pass(condition: Code, failAction?: () => void): void; - // (undocumented) - reset(): void; - // (undocumented) - result(condition: Code, successAction?: () => void, failAction?: () => void): void; - // (undocumented) - schema: any; - // (undocumented) - readonly schemaCode: Code | number | boolean; - // (undocumented) - readonly schemaType: JSONType[]; - // (undocumented) - readonly schemaValue: Code | number | boolean; - // (undocumented) - setParams(obj: KeywordCxtParams, assign?: true): void; - // (undocumented) - subschema(appl: SubschemaArgs, valid: Name): SchemaCxt; -} - -// @public (undocumented) -type KeywordCxtParams = { [P in string]?: Code | string | number }; - -// @public (undocumented) -type KeywordDefinition = CodeKeywordDefinition | FuncKeywordDefinition | MacroKeywordDefinition; - -// @public (undocumented) -interface KeywordErrorCxt { - // (undocumented) - $data?: string | false; - // (undocumented) - data: Name; - // (undocumented) - errsCount?: Name; - // (undocumented) - gen: CodeGen; - // (undocumented) - it: SchemaCxt; - // (undocumented) - keyword: string; - // (undocumented) - params: KeywordCxtParams; - // (undocumented) - parentSchema?: AnySchemaObject; - // (undocumented) - schema: any; - // (undocumented) - schemaCode: Code | number | boolean; - // (undocumented) - schemaType?: JSONType[]; - // (undocumented) - schemaValue: Code | number | boolean; -} - -// @public (undocumented) -interface KeywordErrorDefinition { - // (undocumented) - message: string | Code | ((cxt: KeywordErrorCxt) => string | Code); - // (undocumented) - params?: Code | ((cxt: KeywordErrorCxt) => Code); -} - -// @public (undocumented) -type Known = { - [key: string]: Known; -} | [Known, ...Known[]] | Known[] | number | string | boolean | null; - -// @public (undocumented) -type LocalRefs = { [Ref in string]?: AnySchemaObject }; - -// @public (undocumented) -interface Logger { - // (undocumented) - error(...args: unknown[]): unknown; - // (undocumented) - log(...args: unknown[]): unknown; - // (undocumented) - warn(...args: unknown[]): unknown; -} - -// @public (undocumented) -interface MacroKeywordDefinition extends FuncKeywordDefinition { - // (undocumented) - macro: MacroKeywordFunc; -} - -// @public (undocumented) -type MacroKeywordFunc = (schema: any, parentSchema: AnySchemaObject, it: SchemaCxt) => AnySchema; - -// @public (undocumented) -class MissingRefError extends Error { - constructor(resolver: UriResolver, baseId: string, ref: string, msg?: string); - // (undocumented) - readonly missingRef: string; - // (undocumented) - readonly missingSchema: string; -} - -// @public (undocumented) -class Name extends _CodeOrName { - constructor(s: string); - // (undocumented) - emptyStr(): boolean; - // (undocumented) - get names(): UsedNames; - // (undocumented) - readonly str: string; - // (undocumented) - toString(): string; -} - -// @public (undocumented) -interface NameGroup { - // (undocumented) - index: number; - // (undocumented) - prefix: string; -} - -// @public (undocumented) -interface NameValue { - // (undocumented) - code?: Code; - // (undocumented) - key?: unknown; - // (undocumented) - ref: ValueReference; -} - -// @public -type NullTypeEquality = TypeEquality; - -// @public (undocumented) -type Nullable = undefined extends T ? { - nullable: true; - const?: null; - enum?: readonly (T | null)[]; - default?: T | null; -} : { - nullable?: false; - const?: T; - enum?: readonly T[]; - default?: T; -}; - -// @public (undocumented) -interface NumberKeywords { - // (undocumented) - exclusiveMaximum?: number; - // (undocumented) - exclusiveMinimum?: number; - // (undocumented) - format?: string; - // (undocumented) - maximum?: number; - // (undocumented) - minimum?: number; - // (undocumented) - multipleOf?: number; -} - -// @public -type NumberType = "float32" | "float64" | "int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32"; - -// @public -type OptionalKeys = { [K in keyof T]-?: undefined extends T[K] ? K : never }[keyof T]; - -// @public (undocumented) -type Options = CurrentOptions & DeprecatedOptions; - -// @public (undocumented) -interface Plugin_2 { - // (undocumented) - (ajv: Ajv$2, options?: Opts): Ajv$2; - // (undocumented) - [prop: string]: any; -} - -// @public (undocumented) -interface RegExpEngine { - // (undocumented) - (pattern: string, u: string): RegExpLike; - // (undocumented) - code: string; -} - -// @public (undocumented) -interface RegExpLike { - // (undocumented) - test: (s: string) => boolean; -} - -// @public (undocumented) -type RequiredInstanceOptions = { [K in "strictSchema" | "strictNumbers" | "strictTypes" | "strictTuples" | "strictRequired" | "inlineRefs" | "loopRequired" | "loopEnum" | "meta" | "messages" | "schemaId" | "addUsedSchema" | "validateSchema" | "validateFormats" | "int32range" | "unicodeRegExp" | "uriResolver"]: NonNullable } & { - code: InstanceCodeOptions; -}; - -// @public -type RequiredKeys = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]; - -// @public (undocumented) -interface Rule { - // (undocumented) - definition: AddedKeywordDefinition; - // (undocumented) - keyword: string; -} - -// @public (undocumented) -interface RuleGroup { - // (undocumented) - rules: Rule[]; - // (undocumented) - type?: JSONType; -} - -// @public (undocumented) -type SafeExpr = Code | number | boolean | null; - -// @public (undocumented) -type Schema = SchemaObject | boolean; - -// @public (undocumented) -interface SchemaCxt { - // (undocumented) - readonly allErrors?: boolean; - // (undocumented) - baseId: string; - // (undocumented) - readonly compositeRule?: boolean; - // (undocumented) - readonly createErrors?: boolean; - // (undocumented) - readonly data: Name; - // (undocumented) - readonly dataLevel: number; - // (undocumented) - readonly dataNames: Name[]; - // (undocumented) - readonly dataPathArr: (Code | number)[]; - // (undocumented) - dataTypes: JSONType[]; - // (undocumented) - definedProperties: Set; - // (undocumented) - readonly errorPath: Code; - // (undocumented) - readonly errSchemaPath: string; - // (undocumented) - evaluated?: Name; - // (undocumented) - readonly gen: CodeGen; - // (undocumented) - items?: EvaluatedItems | Name; - // (undocumented) - jtdDiscriminator?: string; - // (undocumented) - jtdMetadata?: boolean; - // (undocumented) - readonly opts: InstanceOptions; - // (undocumented) - readonly parentData: Name; - // (undocumented) - readonly parentDataProperty: Code | number; - // (undocumented) - readonly propertyName?: Name; - // (undocumented) - props?: EvaluatedProperties | Name; - // (undocumented) - readonly rootId: string; - // (undocumented) - readonly schema: AnySchema; - // (undocumented) - readonly schemaEnv: SchemaEnv; - // (undocumented) - readonly schemaPath: Code; - // (undocumented) - readonly self: Ajv$2; - // (undocumented) - readonly topSchemaRef: Code; - // (undocumented) - readonly validateName: Name; - // (undocumented) - readonly ValidationError?: Name; -} - -// @public (undocumented) -class SchemaEnv implements SchemaEnvArgs { - // (undocumented) - readonly $async?: boolean; - constructor(env: SchemaEnvArgs); - // (undocumented) - baseId: string; - // (undocumented) - readonly dynamicAnchors: { [Ref in string]?: true }; - // (undocumented) - localRefs?: LocalRefs; - // (undocumented) - readonly meta?: boolean; - // (undocumented) - parse?: (data: string) => unknown; - // (undocumented) - parseName?: ValueScopeName; - // (undocumented) - readonly refs: SchemaRefs; - // (undocumented) - readonly root: SchemaEnv; - // (undocumented) - readonly schema: AnySchema; - // (undocumented) - readonly schemaId?: "$id" | "id"; - // (undocumented) - schemaPath?: string; - // (undocumented) - serialize?: (data: unknown) => string; - // (undocumented) - serializeName?: ValueScopeName; - // (undocumented) - validate?: AnyValidateFunction; - // (undocumented) - validateName?: ValueScopeName; -} - -// @public (undocumented) -interface SchemaEnvArgs { - // (undocumented) - readonly baseId?: string; - // (undocumented) - readonly localRefs?: LocalRefs; - // (undocumented) - readonly meta?: boolean; - // (undocumented) - readonly root?: SchemaEnv; - // (undocumented) - readonly schema: AnySchema; - // (undocumented) - readonly schemaId?: "$id" | "id"; - // (undocumented) - readonly schemaPath?: string; -} - -// @public (undocumented) -interface SchemaObjCxt extends SchemaCxt { - // (undocumented) - readonly schema: AnySchemaObject; -} - -// @public (undocumented) -interface SchemaObject extends _SchemaObject { - // (undocumented) - $async?: false; - // (undocumented) - $id?: string; - // (undocumented) - $schema?: string; - // (undocumented) - [x: string]: any; - // (undocumented) - id?: string; -} - -// @public (undocumented) -type SchemaRefs = { [Ref in string]?: SchemaEnv | AnySchema }; - -// @public (undocumented) -interface SchemaValidateFunction { - // (undocumented) - (schema: any, data: any, parentSchema?: AnySchemaObject, dataCxt?: DataValidationCxt): boolean | Promise; - // (undocumented) - errors?: Partial[]; -} - -// @public (undocumented) -class Scope { - constructor(input?: ScopeOptions); - // (undocumented) - name(prefix: string): Name; - // (undocumented) - protected readonly _names: { [Prefix in string]?: NameGroup }; - // (undocumented) - protected _newName(prefix: string): string; - // (undocumented) - protected readonly _parent?: Scope; - // (undocumented) - protected readonly _prefixes?: Set; - // (undocumented) - toName(nameOrPrefix: Name | string): Name; -} - -// @public (undocumented) -interface ScopeOptions { - // (undocumented) - parent?: Scope; - // (undocumented) - prefixes?: Set; -} - -// @public (undocumented) -interface ScopePath { - // (undocumented) - itemIndex: number; - // (undocumented) - property: string; -} - -// @public (undocumented) -type ScopeStore = Record; - -// @public (undocumented) -type ScopeValueSets = { [Prefix in string]?: Set }; - -// @public (undocumented) -type ScopeValues = { [Prefix in string]?: Map }; - -// @public -type SomeJTDSchemaType = ( -// ref - { - ref: string; -} | { - type: NumberType | StringType | "boolean"; -} | { - enum: string[]; -} | { - elements: SomeJTDSchemaType; -} | { - values: SomeJTDSchemaType; -} | { - properties: Record; - optionalProperties?: Record; - additionalProperties?: boolean; -} | { - properties?: Record; - optionalProperties: Record; - additionalProperties?: boolean; -} | { - discriminator: string; - mapping: Record; -} | {}) & { - nullable?: boolean; - metadata?: Record; - definitions?: Record; -}; - -// @public (undocumented) -interface SourceCode { - // (undocumented) - evaluated?: Code; - // (undocumented) - scopeValues: ScopeValueSets; - // (undocumented) - validateCode: string; - // (undocumented) - validateName: ValueScopeName; -} - -// @public (undocumented) -type StrictNullChecksWrapper = undefined extends null ? `strictNullChecks must be true in tsconfig to use ${Name}` : Type; - -// @public (undocumented) -interface StringKeywords { - // (undocumented) - format?: string; - // (undocumented) - maxLength?: number; - // (undocumented) - minLength?: number; - // (undocumented) - pattern?: string; -} - -// @public -type StringType = "string" | "timestamp"; - -// @public (undocumented) -type SubschemaArgs = Partial<{ - keyword: string; - schemaProp: string | number; - schema: AnySchema; - schemaPath: Code; - errSchemaPath: string; - topSchemaRef: Code; - data: Name | Code; - dataProp: Code | string | number; - dataTypes: JSONType[]; - definedProperties: Set; - propertyName: Name; - dataPropType: Type; - jtdDiscriminator: string; - jtdMetadata: boolean; - compositeRule: true; - createErrors: boolean; - allErrors: boolean; -}>; - -// @public (undocumented) -enum Type { - // (undocumented) - Num = 0, - // (undocumented) - Str = 1, -} - -// @public -type TypeEquality = [T] extends [E] ? ([E] extends [T] ? true : false) : false; - -// @public (undocumented) -type UncheckedJSONSchemaType = ( -// these two unions allow arbitrary unions of types - { - anyOf: readonly UncheckedJSONSchemaType[]; -} | { - oneOf: readonly UncheckedJSONSchemaType[]; -} | ({ - type: readonly (T extends number ? JSONType$2<"number" | "integer", IsPartial> : T extends string ? JSONType$2<"string", IsPartial> : T extends boolean ? JSONType$2<"boolean", IsPartial> : never)[]; -} & UnionToIntersection) | ((T extends number ? { - type: JSONType$2<"number" | "integer", IsPartial>; -} & NumberKeywords : T extends string ? { - type: JSONType$2<"string", IsPartial>; -} & StringKeywords : T extends boolean ? { - type: JSONType$2<"boolean", IsPartial>; -} : T extends readonly [any, ...any[]] ? { - type: JSONType$2<"array", IsPartial>; - items: { readonly [K in keyof T]-?: UncheckedJSONSchemaType & Nullable } & { - length: T["length"]; - }; - minItems: T["length"]; -} & ({ - maxItems: T["length"]; -} | { - additionalItems: false; -}) : T extends readonly any[] ? { - type: JSONType$2<"array", IsPartial>; - items: UncheckedJSONSchemaType; - contains?: UncheckedPartialSchema; - minItems?: number; - maxItems?: number; - minContains?: number; - maxContains?: number; - uniqueItems?: true; - additionalItems?: never; -} : T extends Record ? { - type: JSONType$2<"object", IsPartial>; - additionalProperties?: boolean | UncheckedJSONSchemaType; - unevaluatedProperties?: boolean | UncheckedJSONSchemaType; - properties?: IsPartial extends true ? Partial> : UncheckedPropertiesSchema; - patternProperties?: Record>; - propertyNames?: Omit, "type"> & { - type?: "string"; - }; - dependencies?: { [K in keyof T]?: readonly (keyof T)[] | UncheckedPartialSchema }; - dependentRequired?: { [K in keyof T]?: readonly (keyof T)[] }; - dependentSchemas?: { [K in keyof T]?: UncheckedPartialSchema }; - minProperties?: number; - maxProperties?: number; -} & (IsPartial extends true ? { - required: readonly (keyof T)[]; -} : [UncheckedRequiredMembers] extends [never] ? { - required?: readonly UncheckedRequiredMembers[]; -} : { - required: readonly UncheckedRequiredMembers[]; -}) : T extends null ? { - type: JSONType$2<"null", IsPartial>; - nullable: true; -} : never) & { - allOf?: readonly UncheckedPartialSchema[]; - anyOf?: readonly UncheckedPartialSchema[]; - oneOf?: readonly UncheckedPartialSchema[]; - if?: UncheckedPartialSchema; - then?: UncheckedPartialSchema; - else?: UncheckedPartialSchema; - not?: UncheckedPartialSchema; -})) & { - [keyword: string]: any; - $id?: string; - $ref?: string; - $defs?: Record>; - definitions?: Record>; -}; - -// @public (undocumented) -type UncheckedPartialSchema = Partial>; - -// @public (undocumented) -type UncheckedPropertiesSchema = { [K in keyof T]-?: (UncheckedJSONSchemaType & Nullable) | { - $ref: string; - } }; - -// @public (undocumented) -type UncheckedRequiredMembers = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]; - -// @public (undocumented) -type UnionToIntersection = (U extends any ? (_: U) => void : never) extends ((_: infer I) => void) ? I : never; - -// @public (undocumented) -interface UriResolver { - // (undocumented) - parse(uri: string): URIComponent; - // (undocumented) - resolve(base: string, path: string): string; - // (undocumented) - serialize(component: URIComponent): string; -} - -// @public (undocumented) -type UsedNames = Record; - -// @public (undocumented) -type UsedScopeValues = { [Prefix in string]?: Map }; - -// @public (undocumented) -enum UsedValueState { - // (undocumented) - Completed = 1, - // (undocumented) - Started = 0, -} - -// @public (undocumented) -interface VSOptions extends ValueScopeOptions { - // (undocumented) - _n: Code; -} - -// @public (undocumented) -interface ValidateFunction { - // (undocumented) - (this: Ajv$2 | any, data: any, dataCxt?: DataValidationCxt): data is T; - // (undocumented) - errors?: null | ErrorObject[]; - // (undocumented) - evaluated?: Evaluated; - // (undocumented) - schema: AnySchema; - // (undocumented) - schemaEnv: SchemaEnv; - // (undocumented) - source?: SourceCode; -} - -// @public (undocumented) -class ValidationError extends Error { - constructor(errors: Partial[]); - // (undocumented) - readonly ajv: true; - // (undocumented) - readonly errors: Partial[]; - // (undocumented) - readonly validation: true; -} - -// @public (undocumented) -interface ValidationRules { - // (undocumented) - all: { [Key in string]?: boolean | Rule }; - // (undocumented) - keywords: { [Key in string]?: boolean }; - // (undocumented) - post: RuleGroup; - // (undocumented) - rules: RuleGroup[]; - // (undocumented) - types: ValidationTypes; -} - -// @public (undocumented) -type ValidationTypes = { [K in JSONType]: boolean | RuleGroup | undefined }; - -// @public (undocumented) -type ValueReference = unknown; - -// @public (undocumented) -class ValueScope extends Scope { - constructor(opts: ValueScopeOptions); - // (undocumented) - get(): ScopeStore; - // (undocumented) - getValue(prefix: string, keyOrRef: unknown): ValueScopeName | undefined; - // (undocumented) - name(prefix: string): ValueScopeName; - // (undocumented) - readonly opts: VSOptions; - // (undocumented) - protected readonly _scope: ScopeStore; - // (undocumented) - scopeCode(values?: ScopeValues | ScopeValueSets, usedValues?: UsedScopeValues, getCode?: (n: ValueScopeName) => Code | undefined): Code; - // (undocumented) - scopeRefs(scopeName: Name, values?: ScopeValues | ScopeValueSets): Code; - // (undocumented) - value(nameOrPrefix: ValueScopeName | string, value: NameValue): ValueScopeName; - // (undocumented) - protected readonly _values: ScopeValues; -} - -// @public (undocumented) -class ValueScopeName extends Name { - constructor(prefix: string, nameStr: string); - // (undocumented) - readonly prefix: string; - // (undocumented) - scopePath?: Code; - // (undocumented) - setValue(value: NameValue, input: ScopePath): void; - // (undocumented) - value?: NameValue; -} - -// @public (undocumented) -interface ValueScopeOptions extends ScopeOptions { - // (undocumented) - es5?: boolean; - // (undocumented) - lines?: boolean; - // (undocumented) - scope: ScopeStore; -} - -// @public (undocumented) -type Vocabulary = (KeywordDefinition | string)[]; - -// @public (undocumented) -class _Code extends _CodeOrName { - constructor(code: string | readonly CodeItem[]); - // (undocumented) - emptyStr(): boolean; - // (undocumented) - readonly _items: readonly CodeItem[]; - // (undocumented) - get names(): UsedNames; - // (undocumented) - get str(): string; - // (undocumented) - toString(): string; -} - -// @public (undocumented) -abstract class _CodeOrName { - // (undocumented) - abstract emptyStr(): boolean; - // (undocumented) - abstract readonly names: UsedNames; - // (undocumented) - abstract readonly str: string; - // (undocumented) - abstract toString(): string; -} - -// @public (undocumented) -interface _KeywordDef { - // (undocumented) - $data?: boolean; - // (undocumented) - $dataError?: KeywordErrorDefinition; - // (undocumented) - allowUndefined?: boolean; - // (undocumented) - before?: string; - // (undocumented) - dependencies?: string[]; - // (undocumented) - error?: KeywordErrorDefinition; - // (undocumented) - implements?: string[]; - // (undocumented) - keyword: string | string[]; - // (undocumented) - metaSchema?: AnySchemaObject; - // (undocumented) - post?: boolean; - // (undocumented) - schemaType?: JSONType | JSONType[]; - // (undocumented) - type?: JSONType | JSONType[]; - // (undocumented) - validateSchema?: AnyValidateFunction; -} - -// @public (undocumented) -interface _SchemaObject { - // (undocumented) - $id?: string; - // (undocumented) - $schema?: string; - // (undocumented) - [x: string]: any; - // (undocumented) - id?: string; -} - -// @public (undocumented) -const _jsonTypes: readonly ["string", "number", "integer", "boolean", "null", "object", "array"]; - -// @public -export const addFormats: typeof formatsPlugin.default; - -// @public (undocumented) -const formatsPlugin: FormatsPlugin; - -// @public -interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/client/etc/client.validators-cf-worker.api.md b/packages/client/etc/client.validators-cf-worker.api.md deleted file mode 100644 index c9cc131279..0000000000 --- a/packages/client/etc/client.validators-cf-worker.api.md +++ /dev/null @@ -1,44 +0,0 @@ -## API Report File for "@modelcontextprotocol/client" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { JSONSchema } from 'json-schema-typed'; - -// @public -export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { - constructor(options?: { - shortcircuit?: boolean; - draft?: CfWorkerSchemaDraft; - }); - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; - -// @public -type JsonSchemaType = JSONSchema.Interface; - -// @public -type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public -interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/core/test/types/wireOnlyHiding.test.ts b/packages/core/test/types/wireOnlyHiding.test.ts index 0d89b8bbdb..8b2ea7526d 100644 --- a/packages/core/test/types/wireOnlyHiding.test.ts +++ b/packages/core/test/types/wireOnlyHiding.test.ts @@ -1,8 +1,7 @@ /** * Public-face hiding pins: wire-only members and task vocabulary. * - * Two contracts, enforced at the type level and against the committed API - * reports (which the api-report CI gate keeps in lockstep with the dts): + * Two contracts, enforced at the type level: * * 1. Wire-only members are absent from every public result type. `resultType` * is the 2026-07-28 wire discrimination field; the SDK consumes it at the @@ -151,38 +150,6 @@ describe('task vocabulary is importable but in no API signature', () => { }); }); -describe('API-report signature scan (no task type in any public signature)', () => { - const TASK_TYPE_NAME = - /\b(Task|TaskStatus|TaskCreationParams|TaskMetadata|RelatedTaskMetadata|CreateTaskResult|TaskStatusNotification|TaskStatusNotificationParams|GetTaskRequest|GetTaskResult|GetTaskPayloadRequest|GetTaskPayloadResult|ListTasksRequest|ListTasksResult|CancelTaskRequest|CancelTaskResult|TaskAugmentedRequestParams|TaskRequestMethod|TaskNotificationMethod)\b/; - - /** - * Declarations allowed to mention task type names: - * - the deprecated vocabulary cluster itself (Task* types, their schemas, - * the deprecated guard), and - * - the map declarations whose entire job is SUBTRACTING the task methods - * (the Exclude<> helpers and the four typed maps that use them). - */ - const EXEMPT_DECLARATION = new RegExp( - [ - '(export )?(type|const) (Task|CreateTask|GetTask|ListTasks|CancelTask|RelatedTaskMetadata|TaskAugmented)', - 'export const isTaskAugmentedRequestParams', - 'export type (RequestMethod|NotificationMethod|RequestTypeMap|NotificationTypeMap) =' - ].join('|') - ); - - const reports = ['../../../client/etc/client.api.md', '../../../server/etc/server.api.md']; - - test.each(reports)('%s', relPath => { - const report = readFileSync(join(__dirname, relPath), 'utf8'); - // Blocks are separated by blank lines in the API report format. - const blocks = report.split(/\n\s*\n/); - const offending: string[] = []; - for (const block of blocks) { - if (!TASK_TYPE_NAME.test(block)) continue; - if (block.includes('@deprecated')) continue; - if (EXEMPT_DECLARATION.test(block)) continue; - offending.push(block.trim().split('\n').slice(0, 3).join('\n')); - } - expect(offending, 'public declarations mentioning task types outside the deprecated vocabulary cluster').toEqual([]); - }); -}); +// A generated-declaration scan (no task type name in any public signature) used +// to live here; the type-level exclusion tests above pin the same contract +// directly against the source types, so the substance stays covered. diff --git a/packages/middleware/express/etc/express.api.md b/packages/middleware/express/etc/express.api.md deleted file mode 100644 index 5cd7c35860..0000000000 --- a/packages/middleware/express/etc/express.api.md +++ /dev/null @@ -1,95 +0,0 @@ -## API Report File for "@modelcontextprotocol/express" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { Express as Express_2 } from 'express'; -import { RequestHandler } from 'express'; -import { Router } from 'express'; -import * as z from 'zod/v4'; - -// @public -interface AuthInfo { - clientId: string; - expiresAt?: number; - extra?: Record; - resource?: URL; - scopes: string[]; - token: string; -} - -// @public -export interface AuthMetadataOptions { - oauthMetadata: OAuthMetadata; - resourceName?: string; - resourceServerUrl: URL; - scopesSupported?: string[]; - serviceDocumentationUrl?: URL; -} - -// @public -export interface BearerAuthMiddlewareOptions { - requiredScopes?: string[]; - resourceMetadataUrl?: string; - verifier: OAuthTokenVerifier; -} - -// @public -export interface CreateMcpExpressAppOptions { - allowedHosts?: string[]; - host?: string; - jsonLimit?: string; -} - -// @public -type OAuthMetadata = z.infer; - -// @public -const OAuthMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - revocation_endpoint: z.ZodOptional; - revocation_endpoint_auth_methods_supported: z.ZodOptional>; - revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - introspection_endpoint: z.ZodOptional; - introspection_endpoint_auth_methods_supported: z.ZodOptional>; - introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - code_challenge_methods_supported: z.ZodOptional>; - client_id_metadata_document_supported: z.ZodOptional; -}, z.core.$loose>; - -// @public -export interface OAuthTokenVerifier { - verifyAccessToken(token: string): Promise; -} - -// @public -export function createMcpExpressApp(options?: CreateMcpExpressAppOptions): Express_2; - -// @public -export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string; - -// @public -export function hostHeaderValidation(allowedHostnames: string[]): RequestHandler; - -// @public -export function localhostHostValidation(): RequestHandler; - -// @public -export function mcpAuthMetadataRouter(options: AuthMetadataOptions): Router; - -// @public -export function requireBearerAuth(input: BearerAuthMiddlewareOptions): RequestHandler; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/middleware/fastify/etc/fastify.api.md b/packages/middleware/fastify/etc/fastify.api.md deleted file mode 100644 index 718d77e99e..0000000000 --- a/packages/middleware/fastify/etc/fastify.api.md +++ /dev/null @@ -1,27 +0,0 @@ -## API Report File for "@modelcontextprotocol/fastify" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { FastifyInstance } from 'fastify'; -import { FastifyReply } from 'fastify'; -import { FastifyRequest } from 'fastify'; - -// @public -export interface CreateMcpFastifyAppOptions { - allowedHosts?: string[]; - host?: string; -} - -// @public -export function createMcpFastifyApp(options?: CreateMcpFastifyAppOptions): FastifyInstance; - -// @public -export function hostHeaderValidation(allowedHostnames: string[]): (request: FastifyRequest, reply: FastifyReply) => Promise; - -// @public -export function localhostHostValidation(): (request: FastifyRequest, reply: FastifyReply) => Promise; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/middleware/hono/etc/hono.api.md b/packages/middleware/hono/etc/hono.api.md deleted file mode 100644 index e3266bd9e0..0000000000 --- a/packages/middleware/hono/etc/hono.api.md +++ /dev/null @@ -1,26 +0,0 @@ -## API Report File for "@modelcontextprotocol/hono" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { Hono } from 'hono'; -import { MiddlewareHandler } from 'hono'; - -// @public -export interface CreateMcpHonoAppOptions { - allowedHosts?: string[]; - host?: string; -} - -// @public -export function createMcpHonoApp(options?: CreateMcpHonoAppOptions): Hono; - -// @public -export function hostHeaderValidation(allowedHostnames: string[]): MiddlewareHandler; - -// @public -export function localhostHostValidation(): MiddlewareHandler; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/middleware/node/etc/node.api.md b/packages/middleware/node/etc/node.api.md deleted file mode 100644 index 7f71921290..0000000000 --- a/packages/middleware/node/etc/node.api.md +++ /dev/null @@ -1,218 +0,0 @@ -## API Report File for "@modelcontextprotocol/node" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { IncomingMessage } from 'node:http'; -import { ServerResponse } from 'node:http'; -import * as z from 'zod/v4'; - -// @public -interface AuthInfo { - clientId: string; - expiresAt?: number; - extra?: Record; - resource?: URL; - scopes: string[]; - token: string; -} - -// @public (undocumented) -type EventId = string; - -// @public -interface EventStore { - getStreamIdForEventId?(eventId: EventId): Promise; - // (undocumented) - replayEventsAfter(lastEventId: EventId, input: { - send: (eventId: EventId, message: JSONRPCMessage) => Promise; - }): Promise; - storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; -} - -// @public (undocumented) -type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; - -// @public (undocumented) -type Infer = Flatten>; - -// @public (undocumented) -type JSONRPCErrorResponse = Infer; - -// @public -const JSONRPCErrorResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; - -// @public (undocumented) -type JSONRPCNotification = Infer; - -// @public -const JSONRPCNotificationSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCRequest = Infer; - -// @public -const JSONRPCRequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCResultResponse = Omit, 'result'> & { - result: Result; -}; - -// @public -const JSONRPCResultResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>; - -// @public -interface MessageExtraInfo { - authInfo?: AuthInfo; - closeSSEStream?: () => void; - closeStandaloneSSEStream?: () => void; - request?: globalThis.Request; -} - -// @public -export class NodeStreamableHTTPServerTransport implements Transport { - constructor(options?: StreamableHTTPServerTransportOptions); - close(): Promise; - closeSSEStream(requestId: RequestId): void; - closeStandaloneSSEStream(): void; - handleRequest(req: IncomingMessage & { - auth?: AuthInfo; - }, res: ServerResponse, parsedBody?: unknown): Promise; - set onclose(handler: (() => void) | undefined); - // (undocumented) - get onclose(): (() => void) | undefined; - set onerror(handler: ((error: Error) => void) | undefined); - // (undocumented) - get onerror(): ((error: Error) => void) | undefined; - set onmessage(handler: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined); - // (undocumented) - get onmessage(): ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined; - send(message: JSONRPCMessage, options?: { - relatedRequestId?: RequestId; - }): Promise; - get sessionId(): string | undefined; - start(): Promise; -} - -// @public (undocumented) -type Primitive = string | number | boolean | bigint | null | undefined; - -// @public (undocumented) -type RequestId = Infer; - -// @public -const RequestIdSchema: z.ZodUnion; - -// @public (undocumented) -type Result = StripWireOnly>; - -// @public (undocumented) -const ResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -type StreamId = string; - -// @public -export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServerTransportOptions; - -// @public -type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; - -// @public -interface Transport { - close(): Promise; - onclose?: (() => void) | undefined; - onerror?: ((error: Error) => void) | undefined; - onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; - send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; - sessionId?: string | undefined; - setProtocolVersion?: ((version: string) => void) | undefined; - setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; - start(): Promise; -} - -// @public -type TransportSendOptions = { - relatedRequestId?: RequestId | undefined; - resumptionToken?: string | undefined; - onresumptiontoken?: ((token: string) => void) | undefined; -}; - -// @public -interface WebStandardStreamableHTTPServerTransportOptions { - // @deprecated - allowedHosts?: string[]; - // @deprecated - allowedOrigins?: string[]; - // @deprecated - enableDnsRebindingProtection?: boolean; - enableJsonResponse?: boolean; - eventStore?: EventStore; - onsessionclosed?: ((sessionId: string) => void | Promise) | undefined; - onsessioninitialized?: ((sessionId: string) => void | Promise) | undefined; - retryInterval?: number; - sessionIdGenerator?: (() => string) | undefined; - supportedProtocolVersions?: string[]; -} - -// @public -type WireOnlyResultKey = 'resultType'; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/server-legacy/etc/server-legacy.api.md b/packages/server-legacy/etc/server-legacy.api.md deleted file mode 100644 index c41b516996..0000000000 --- a/packages/server-legacy/etc/server-legacy.api.md +++ /dev/null @@ -1,631 +0,0 @@ -## API Report File for "@modelcontextprotocol/server-legacy" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import express from 'express'; -import { IncomingMessage } from 'node:http'; -import { Options } from 'express-rate-limit'; -import { RequestHandler } from 'express'; -import { Response as Response_2 } from 'express'; -import { ServerResponse } from 'node:http'; -import * as z from 'zod/v4'; - -// @public -export class AccessDeniedError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export interface AuthInfo { - clientId: string; - expiresAt?: number; - extra?: Record; - resource?: URL; - scopes: string[]; - token: string; -} - -// @public (undocumented) -export type AuthMetadataOptions = { - oauthMetadata: OAuthMetadata; - resourceServerUrl: URL; - serviceDocumentationUrl?: URL; - scopesSupported?: string[]; - resourceName?: string; -}; - -// @public (undocumented) -export type AuthRouterOptions = { - provider: OAuthServerProvider; - issuerUrl: URL; - baseUrl?: URL; - serviceDocumentationUrl?: URL; - scopesSupported?: string[]; - resourceName?: string; - resourceServerUrl?: URL; - authorizationOptions?: Omit; - clientRegistrationOptions?: Omit; - revocationOptions?: Omit; - tokenOptions?: Omit; -}; - -// @public (undocumented) -export type AuthorizationHandlerOptions = { - provider: OAuthServerProvider; - rateLimit?: Partial | false; -}; - -// @public (undocumented) -export type AuthorizationParams = { - state?: string; - scopes?: string[]; - codeChallenge: string; - redirectUri: string; - resource?: URL; -}; - -// @public (undocumented) -export type BearerAuthMiddlewareOptions = { - verifier: OAuthTokenVerifier; - requiredScopes?: string[]; - resourceMetadataUrl?: string; -}; - -// @public (undocumented) -export type ClientAuthenticationMiddlewareOptions = { - clientsStore: OAuthRegisteredClientsStore; -}; - -// @public (undocumented) -export type ClientRegistrationHandlerOptions = { - clientsStore: OAuthRegisteredClientsStore; - clientSecretExpirySeconds?: number; - rateLimit?: Partial | false; - clientIdGeneration?: boolean; -}; - -// @public -export class CustomOAuthError extends OAuthError { - constructor(customErrorCode: string, message: string, errorUri?: string); - // (undocumented) - get errorCode(): string; -} - -// @public (undocumented) -type FetchLike = (url: string | URL, init?: RequestInit) => Promise; - -// @public (undocumented) -type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; - -// @public (undocumented) -type Infer = Flatten>; - -// @public -export class InsufficientScopeError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidClientError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidClientMetadataError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidGrantError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidRequestError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidScopeError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidTargetError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidTokenError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public (undocumented) -type JSONRPCErrorResponse = Infer; - -// @public -const JSONRPCErrorResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; - -// @public (undocumented) -type JSONRPCNotification = Infer; - -// @public -const JSONRPCNotificationSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCRequest = Infer; - -// @public -const JSONRPCRequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCResultResponse = Omit, 'result'> & { - result: Result; -}; - -// @public -const JSONRPCResultResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>; - -// @public -interface MessageExtraInfo { - authInfo?: AuthInfo; - closeSSEStream?: () => void; - closeStandaloneSSEStream?: () => void; - request?: globalThis.Request; -} - -// @public -export class MethodNotAllowedError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export const OAUTH_ERRORS: { - readonly [InvalidRequestError.errorCode]: typeof InvalidRequestError; - readonly [InvalidClientError.errorCode]: typeof InvalidClientError; - readonly [InvalidGrantError.errorCode]: typeof InvalidGrantError; - readonly [UnauthorizedClientError.errorCode]: typeof UnauthorizedClientError; - readonly [UnsupportedGrantTypeError.errorCode]: typeof UnsupportedGrantTypeError; - readonly [InvalidScopeError.errorCode]: typeof InvalidScopeError; - readonly [AccessDeniedError.errorCode]: typeof AccessDeniedError; - readonly [ServerError.errorCode]: typeof ServerError; - readonly [TemporarilyUnavailableError.errorCode]: typeof TemporarilyUnavailableError; - readonly [UnsupportedResponseTypeError.errorCode]: typeof UnsupportedResponseTypeError; - readonly [UnsupportedTokenTypeError.errorCode]: typeof UnsupportedTokenTypeError; - readonly [InvalidTokenError.errorCode]: typeof InvalidTokenError; - readonly [MethodNotAllowedError.errorCode]: typeof MethodNotAllowedError; - readonly [TooManyRequestsError.errorCode]: typeof TooManyRequestsError; - readonly [InvalidClientMetadataError.errorCode]: typeof InvalidClientMetadataError; - readonly [InsufficientScopeError.errorCode]: typeof InsufficientScopeError; - readonly [InvalidTargetError.errorCode]: typeof InvalidTargetError; -}; - -// @public (undocumented) -type OAuthClientInformationFull = z.infer; - -// @public -const OAuthClientInformationFullSchema: z.ZodObject<{ - redirect_uris: z.ZodArray; - token_endpoint_auth_method: z.ZodOptional; - grant_types: z.ZodOptional>; - response_types: z.ZodOptional>; - client_name: z.ZodOptional; - client_uri: z.ZodOptional; - logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - scope: z.ZodOptional; - contacts: z.ZodOptional>; - tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - policy_uri: z.ZodOptional; - jwks_uri: z.ZodOptional; - jwks: z.ZodOptional; - software_id: z.ZodOptional; - software_version: z.ZodOptional; - software_statement: z.ZodOptional; - client_id: z.ZodString; - client_secret: z.ZodOptional; - client_id_issued_at: z.ZodOptional; - client_secret_expires_at: z.ZodOptional; -}, z.core.$strip>; - -// @public -export class OAuthError extends Error { - constructor(message: string, errorUri?: string | undefined); - // (undocumented) - static errorCode: string; - // (undocumented) - get errorCode(): string; - // (undocumented) - readonly errorUri?: string | undefined; - toResponseObject(): OAuthErrorResponse; -} - -// @public (undocumented) -type OAuthErrorResponse = z.infer; - -// @public -const OAuthErrorResponseSchema: z.ZodObject<{ - error: z.ZodString; - error_description: z.ZodOptional; - error_uri: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -type OAuthMetadata = z.infer; - -// @public -const OAuthMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - revocation_endpoint: z.ZodOptional; - revocation_endpoint_auth_methods_supported: z.ZodOptional>; - revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - introspection_endpoint: z.ZodOptional; - introspection_endpoint_auth_methods_supported: z.ZodOptional>; - introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - code_challenge_methods_supported: z.ZodOptional>; - client_id_metadata_document_supported: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -type OAuthProtectedResourceMetadata = z.infer; - -// @public -const OAuthProtectedResourceMetadataSchema: z.ZodObject<{ - resource: z.ZodString; - authorization_servers: z.ZodOptional>; - jwks_uri: z.ZodOptional; - scopes_supported: z.ZodOptional>; - bearer_methods_supported: z.ZodOptional>; - resource_signing_alg_values_supported: z.ZodOptional>; - resource_name: z.ZodOptional; - resource_documentation: z.ZodOptional; - resource_policy_uri: z.ZodOptional; - resource_tos_uri: z.ZodOptional; - tls_client_certificate_bound_access_tokens: z.ZodOptional; - authorization_details_types_supported: z.ZodOptional>; - dpop_signing_alg_values_supported: z.ZodOptional>; - dpop_bound_access_tokens_required: z.ZodOptional; -}, z.core.$loose>; - -// @public -export interface OAuthRegisteredClientsStore { - getClient(clientId: string): OAuthClientInformationFull | undefined | Promise; - registerClient?(client: Omit): OAuthClientInformationFull | Promise; -} - -// @public -export interface OAuthServerProvider { - authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response_2): Promise; - challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; - get clientsStore(): OAuthRegisteredClientsStore; - exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, redirectUri?: string, resource?: URL): Promise; - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; - revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; - skipLocalPkceValidation?: boolean; - verifyAccessToken(token: string): Promise; -} - -// @public (undocumented) -type OAuthTokenRevocationRequest = z.infer; - -// @public -const OAuthTokenRevocationRequestSchema: z.ZodObject<{ - token: z.ZodString; - token_type_hint: z.ZodOptional; -}, z.core.$strip>; - -// @public -export interface OAuthTokenVerifier { - verifyAccessToken(token: string): Promise; -} - -// @public (undocumented) -type OAuthTokens = z.infer; - -// @public -const OAuthTokensSchema: z.ZodObject<{ - access_token: z.ZodString; - id_token: z.ZodOptional; - token_type: z.ZodString; - expires_in: z.ZodOptional>; - scope: z.ZodOptional; - refresh_token: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -type Primitive = string | number | boolean | bigint | null | undefined; - -// @public (undocumented) -export type ProxyEndpoints = { - authorizationUrl: string; - tokenUrl: string; - revocationUrl?: string; - registrationUrl?: string; -}; - -// @public -export class ProxyOAuthServerProvider implements OAuthServerProvider { - constructor(options: ProxyOptions); - // (undocumented) - authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response_2): Promise; - // (undocumented) - challengeForAuthorizationCode(_client: OAuthClientInformationFull, _authorizationCode: string): Promise; - // (undocumented) - get clientsStore(): OAuthRegisteredClientsStore; - // (undocumented) - protected readonly _endpoints: ProxyEndpoints; - // (undocumented) - exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, redirectUri?: string, resource?: URL): Promise; - // (undocumented) - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; - // (undocumented) - protected readonly _fetch?: FetchLike; - // (undocumented) - protected readonly _getClient: (clientId: string) => Promise; - // (undocumented) - revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise; - // (undocumented) - skipLocalPkceValidation: boolean; - // (undocumented) - verifyAccessToken(token: string): Promise; - // (undocumented) - protected readonly _verifyAccessToken: (token: string) => Promise; -} - -// @public (undocumented) -export type ProxyOptions = { - endpoints: ProxyEndpoints; - verifyAccessToken: (token: string) => Promise; - getClient: (clientId: string) => Promise; - fetch?: FetchLike; -}; - -// @public (undocumented) -type RequestId = Infer; - -// @public -const RequestIdSchema: z.ZodUnion; - -// @public (undocumented) -type Result = StripWireOnly>; - -// @public (undocumented) -const ResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export type RevocationHandlerOptions = { - provider: OAuthServerProvider; - rateLimit?: Partial | false; -}; - -// @public @deprecated -export class SSEServerTransport implements Transport { - constructor(_endpoint: string, res: ServerResponse, options?: SSEServerTransportOptions); - // (undocumented) - close(): Promise; - // (undocumented) - handleMessage(message: unknown, extra?: MessageExtraInfo): Promise; - // (undocumented) - handlePostMessage(req: IncomingMessage & { - auth?: AuthInfo; - }, res: ServerResponse, parsedBody?: unknown): Promise; - // (undocumented) - onclose?: () => void; - // (undocumented) - onerror?: ((error: Error) => void) | undefined; - // (undocumented) - onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; - // (undocumented) - send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise; - // (undocumented) - get sessionId(): string; - // (undocumented) - start(): Promise; -} - -// @public @deprecated -export interface SSEServerTransportOptions { - // @deprecated (undocumented) - allowedHosts?: string[]; - // @deprecated (undocumented) - allowedOrigins?: string[]; - // @deprecated (undocumented) - enableDnsRebindingProtection?: boolean; -} - -// @public -export class ServerError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; - -// @public -export class TemporarilyUnavailableError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public (undocumented) -export type TokenHandlerOptions = { - provider: OAuthServerProvider; - rateLimit?: Partial | false; -}; - -// @public -export class TooManyRequestsError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -interface Transport { - close(): Promise; - onclose?: (() => void) | undefined; - onerror?: ((error: Error) => void) | undefined; - onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; - send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; - sessionId?: string | undefined; - setProtocolVersion?: ((version: string) => void) | undefined; - setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; - start(): Promise; -} - -// @public -type TransportSendOptions = { - relatedRequestId?: RequestId | undefined; - resumptionToken?: string | undefined; - onresumptiontoken?: ((token: string) => void) | undefined; -}; - -// @public -export class UnauthorizedClientError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class UnsupportedGrantTypeError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class UnsupportedResponseTypeError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class UnsupportedTokenTypeError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -type WireOnlyResultKey = 'resultType'; - -// @public -export function allowedMethods(allowedMethods: string[]): RequestHandler; - -// @public (undocumented) -export function authenticateClient(input: ClientAuthenticationMiddlewareOptions): RequestHandler; - -// @public (undocumented) -export function authorizationHandler(input: AuthorizationHandlerOptions): RequestHandler; - -// @public (undocumented) -export function clientRegistrationHandler(input: ClientRegistrationHandlerOptions): RequestHandler; - -// @public (undocumented) -export const createOAuthMetadata: (options: { - provider: OAuthServerProvider; - issuerUrl: URL; - baseUrl?: URL; - serviceDocumentationUrl?: URL; - scopesSupported?: string[]; -}) => OAuthMetadata; - -// @public -export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string; - -// @public (undocumented) -export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router; - -// @public -export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler; - -// @public (undocumented) -export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler; - -// @public -export function redirectUriMatches(requested: string, registered: string): boolean; - -// @public -export function requireBearerAuth(input: BearerAuthMiddlewareOptions): RequestHandler; - -// @public (undocumented) -export function revocationHandler(input: RevocationHandlerOptions): RequestHandler; - -// @public (undocumented) -export function tokenHandler(input: TokenHandlerOptions): RequestHandler; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/server-legacy/etc/server-legacy.auth.api.md b/packages/server-legacy/etc/server-legacy.auth.api.md deleted file mode 100644 index 9b0e11439d..0000000000 --- a/packages/server-legacy/etc/server-legacy.auth.api.md +++ /dev/null @@ -1,459 +0,0 @@ -## API Report File for "@modelcontextprotocol/server-legacy" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import express from 'express'; -import { Options } from 'express-rate-limit'; -import { RequestHandler } from 'express'; -import { Response as Response_2 } from 'express'; -import * as z from 'zod/v4'; - -// @public -export class AccessDeniedError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export interface AuthInfo { - clientId: string; - expiresAt?: number; - extra?: Record; - resource?: URL; - scopes: string[]; - token: string; -} - -// @public (undocumented) -export type AuthMetadataOptions = { - oauthMetadata: OAuthMetadata; - resourceServerUrl: URL; - serviceDocumentationUrl?: URL; - scopesSupported?: string[]; - resourceName?: string; -}; - -// @public (undocumented) -export type AuthRouterOptions = { - provider: OAuthServerProvider; - issuerUrl: URL; - baseUrl?: URL; - serviceDocumentationUrl?: URL; - scopesSupported?: string[]; - resourceName?: string; - resourceServerUrl?: URL; - authorizationOptions?: Omit; - clientRegistrationOptions?: Omit; - revocationOptions?: Omit; - tokenOptions?: Omit; -}; - -// @public (undocumented) -export type AuthorizationHandlerOptions = { - provider: OAuthServerProvider; - rateLimit?: Partial | false; -}; - -// @public (undocumented) -export type AuthorizationParams = { - state?: string; - scopes?: string[]; - codeChallenge: string; - redirectUri: string; - resource?: URL; -}; - -// @public (undocumented) -export type BearerAuthMiddlewareOptions = { - verifier: OAuthTokenVerifier; - requiredScopes?: string[]; - resourceMetadataUrl?: string; -}; - -// @public (undocumented) -export type ClientAuthenticationMiddlewareOptions = { - clientsStore: OAuthRegisteredClientsStore; -}; - -// @public (undocumented) -export type ClientRegistrationHandlerOptions = { - clientsStore: OAuthRegisteredClientsStore; - clientSecretExpirySeconds?: number; - rateLimit?: Partial | false; - clientIdGeneration?: boolean; -}; - -// @public -export class CustomOAuthError extends OAuthError { - constructor(customErrorCode: string, message: string, errorUri?: string); - // (undocumented) - get errorCode(): string; -} - -// @public (undocumented) -type FetchLike = (url: string | URL, init?: RequestInit) => Promise; - -// @public -export class InsufficientScopeError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidClientError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidClientMetadataError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidGrantError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidRequestError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidScopeError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidTargetError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class InvalidTokenError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class MethodNotAllowedError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export const OAUTH_ERRORS: { - readonly [InvalidRequestError.errorCode]: typeof InvalidRequestError; - readonly [InvalidClientError.errorCode]: typeof InvalidClientError; - readonly [InvalidGrantError.errorCode]: typeof InvalidGrantError; - readonly [UnauthorizedClientError.errorCode]: typeof UnauthorizedClientError; - readonly [UnsupportedGrantTypeError.errorCode]: typeof UnsupportedGrantTypeError; - readonly [InvalidScopeError.errorCode]: typeof InvalidScopeError; - readonly [AccessDeniedError.errorCode]: typeof AccessDeniedError; - readonly [ServerError.errorCode]: typeof ServerError; - readonly [TemporarilyUnavailableError.errorCode]: typeof TemporarilyUnavailableError; - readonly [UnsupportedResponseTypeError.errorCode]: typeof UnsupportedResponseTypeError; - readonly [UnsupportedTokenTypeError.errorCode]: typeof UnsupportedTokenTypeError; - readonly [InvalidTokenError.errorCode]: typeof InvalidTokenError; - readonly [MethodNotAllowedError.errorCode]: typeof MethodNotAllowedError; - readonly [TooManyRequestsError.errorCode]: typeof TooManyRequestsError; - readonly [InvalidClientMetadataError.errorCode]: typeof InvalidClientMetadataError; - readonly [InsufficientScopeError.errorCode]: typeof InsufficientScopeError; - readonly [InvalidTargetError.errorCode]: typeof InvalidTargetError; -}; - -// @public (undocumented) -type OAuthClientInformationFull = z.infer; - -// @public -const OAuthClientInformationFullSchema: z.ZodObject<{ - redirect_uris: z.ZodArray; - token_endpoint_auth_method: z.ZodOptional; - grant_types: z.ZodOptional>; - response_types: z.ZodOptional>; - client_name: z.ZodOptional; - client_uri: z.ZodOptional; - logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - scope: z.ZodOptional; - contacts: z.ZodOptional>; - tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - policy_uri: z.ZodOptional; - jwks_uri: z.ZodOptional; - jwks: z.ZodOptional; - software_id: z.ZodOptional; - software_version: z.ZodOptional; - software_statement: z.ZodOptional; - client_id: z.ZodString; - client_secret: z.ZodOptional; - client_id_issued_at: z.ZodOptional; - client_secret_expires_at: z.ZodOptional; -}, z.core.$strip>; - -// @public -export class OAuthError extends Error { - constructor(message: string, errorUri?: string | undefined); - // (undocumented) - static errorCode: string; - // (undocumented) - get errorCode(): string; - // (undocumented) - readonly errorUri?: string | undefined; - toResponseObject(): OAuthErrorResponse; -} - -// @public (undocumented) -type OAuthErrorResponse = z.infer; - -// @public -const OAuthErrorResponseSchema: z.ZodObject<{ - error: z.ZodString; - error_description: z.ZodOptional; - error_uri: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -type OAuthMetadata = z.infer; - -// @public -const OAuthMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - revocation_endpoint: z.ZodOptional; - revocation_endpoint_auth_methods_supported: z.ZodOptional>; - revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - introspection_endpoint: z.ZodOptional; - introspection_endpoint_auth_methods_supported: z.ZodOptional>; - introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - code_challenge_methods_supported: z.ZodOptional>; - client_id_metadata_document_supported: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -type OAuthProtectedResourceMetadata = z.infer; - -// @public -const OAuthProtectedResourceMetadataSchema: z.ZodObject<{ - resource: z.ZodString; - authorization_servers: z.ZodOptional>; - jwks_uri: z.ZodOptional; - scopes_supported: z.ZodOptional>; - bearer_methods_supported: z.ZodOptional>; - resource_signing_alg_values_supported: z.ZodOptional>; - resource_name: z.ZodOptional; - resource_documentation: z.ZodOptional; - resource_policy_uri: z.ZodOptional; - resource_tos_uri: z.ZodOptional; - tls_client_certificate_bound_access_tokens: z.ZodOptional; - authorization_details_types_supported: z.ZodOptional>; - dpop_signing_alg_values_supported: z.ZodOptional>; - dpop_bound_access_tokens_required: z.ZodOptional; -}, z.core.$loose>; - -// @public -export interface OAuthRegisteredClientsStore { - getClient(clientId: string): OAuthClientInformationFull | undefined | Promise; - registerClient?(client: Omit): OAuthClientInformationFull | Promise; -} - -// @public -export interface OAuthServerProvider { - authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response_2): Promise; - challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; - get clientsStore(): OAuthRegisteredClientsStore; - exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, redirectUri?: string, resource?: URL): Promise; - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; - revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; - skipLocalPkceValidation?: boolean; - verifyAccessToken(token: string): Promise; -} - -// @public (undocumented) -type OAuthTokenRevocationRequest = z.infer; - -// @public -const OAuthTokenRevocationRequestSchema: z.ZodObject<{ - token: z.ZodString; - token_type_hint: z.ZodOptional; -}, z.core.$strip>; - -// @public -export interface OAuthTokenVerifier { - verifyAccessToken(token: string): Promise; -} - -// @public (undocumented) -type OAuthTokens = z.infer; - -// @public -const OAuthTokensSchema: z.ZodObject<{ - access_token: z.ZodString; - id_token: z.ZodOptional; - token_type: z.ZodString; - expires_in: z.ZodOptional>; - scope: z.ZodOptional; - refresh_token: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ProxyEndpoints = { - authorizationUrl: string; - tokenUrl: string; - revocationUrl?: string; - registrationUrl?: string; -}; - -// @public -export class ProxyOAuthServerProvider implements OAuthServerProvider { - constructor(options: ProxyOptions); - // (undocumented) - authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response_2): Promise; - // (undocumented) - challengeForAuthorizationCode(_client: OAuthClientInformationFull, _authorizationCode: string): Promise; - // (undocumented) - get clientsStore(): OAuthRegisteredClientsStore; - // (undocumented) - protected readonly _endpoints: ProxyEndpoints; - // (undocumented) - exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, redirectUri?: string, resource?: URL): Promise; - // (undocumented) - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; - // (undocumented) - protected readonly _fetch?: FetchLike; - // (undocumented) - protected readonly _getClient: (clientId: string) => Promise; - // (undocumented) - revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise; - // (undocumented) - skipLocalPkceValidation: boolean; - // (undocumented) - verifyAccessToken(token: string): Promise; - // (undocumented) - protected readonly _verifyAccessToken: (token: string) => Promise; -} - -// @public (undocumented) -export type ProxyOptions = { - endpoints: ProxyEndpoints; - verifyAccessToken: (token: string) => Promise; - getClient: (clientId: string) => Promise; - fetch?: FetchLike; -}; - -// @public (undocumented) -export type RevocationHandlerOptions = { - provider: OAuthServerProvider; - rateLimit?: Partial | false; -}; - -// @public -export class ServerError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class TemporarilyUnavailableError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public (undocumented) -export type TokenHandlerOptions = { - provider: OAuthServerProvider; - rateLimit?: Partial | false; -}; - -// @public -export class TooManyRequestsError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class UnauthorizedClientError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class UnsupportedGrantTypeError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class UnsupportedResponseTypeError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export class UnsupportedTokenTypeError extends OAuthError { - // (undocumented) - static errorCode: string; -} - -// @public -export function allowedMethods(allowedMethods: string[]): RequestHandler; - -// @public (undocumented) -export function authenticateClient(input: ClientAuthenticationMiddlewareOptions): RequestHandler; - -// @public (undocumented) -export function authorizationHandler(input: AuthorizationHandlerOptions): RequestHandler; - -// @public (undocumented) -export function clientRegistrationHandler(input: ClientRegistrationHandlerOptions): RequestHandler; - -// @public (undocumented) -export const createOAuthMetadata: (options: { - provider: OAuthServerProvider; - issuerUrl: URL; - baseUrl?: URL; - serviceDocumentationUrl?: URL; - scopesSupported?: string[]; -}) => OAuthMetadata; - -// @public -export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string; - -// @public (undocumented) -export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router; - -// @public -export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler; - -// @public (undocumented) -export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler; - -// @public -export function redirectUriMatches(requested: string, registered: string): boolean; - -// @public -export function requireBearerAuth(input: BearerAuthMiddlewareOptions): RequestHandler; - -// @public (undocumented) -export function revocationHandler(input: RevocationHandlerOptions): RequestHandler; - -// @public (undocumented) -export function tokenHandler(input: TokenHandlerOptions): RequestHandler; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/server-legacy/etc/server-legacy.sse.api.md b/packages/server-legacy/etc/server-legacy.sse.api.md deleted file mode 100644 index 061f8796c8..0000000000 --- a/packages/server-legacy/etc/server-legacy.sse.api.md +++ /dev/null @@ -1,192 +0,0 @@ -## API Report File for "@modelcontextprotocol/server-legacy" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { IncomingMessage } from 'node:http'; -import { ServerResponse } from 'node:http'; -import * as z from 'zod/v4'; - -// @public -interface AuthInfo { - clientId: string; - expiresAt?: number; - extra?: Record; - resource?: URL; - scopes: string[]; - token: string; -} - -// @public (undocumented) -type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; - -// @public (undocumented) -type Infer = Flatten>; - -// @public (undocumented) -type JSONRPCErrorResponse = Infer; - -// @public -const JSONRPCErrorResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; - -// @public (undocumented) -type JSONRPCNotification = Infer; - -// @public -const JSONRPCNotificationSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCRequest = Infer; - -// @public -const JSONRPCRequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCResultResponse = Omit, 'result'> & { - result: Result; -}; - -// @public -const JSONRPCResultResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>; - -// @public -interface MessageExtraInfo { - authInfo?: AuthInfo; - closeSSEStream?: () => void; - closeStandaloneSSEStream?: () => void; - request?: globalThis.Request; -} - -// @public (undocumented) -type Primitive = string | number | boolean | bigint | null | undefined; - -// @public (undocumented) -type RequestId = Infer; - -// @public -const RequestIdSchema: z.ZodUnion; - -// @public (undocumented) -type Result = StripWireOnly>; - -// @public (undocumented) -const ResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$loose>; - -// @public @deprecated -export class SSEServerTransport implements Transport { - constructor(_endpoint: string, res: ServerResponse, options?: SSEServerTransportOptions); - // (undocumented) - close(): Promise; - // (undocumented) - handleMessage(message: unknown, extra?: MessageExtraInfo): Promise; - // (undocumented) - handlePostMessage(req: IncomingMessage & { - auth?: AuthInfo; - }, res: ServerResponse, parsedBody?: unknown): Promise; - // (undocumented) - onclose?: () => void; - // (undocumented) - onerror?: ((error: Error) => void) | undefined; - // (undocumented) - onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; - // (undocumented) - send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise; - // (undocumented) - get sessionId(): string; - // (undocumented) - start(): Promise; -} - -// @public @deprecated -export interface SSEServerTransportOptions { - // @deprecated (undocumented) - allowedHosts?: string[]; - // @deprecated (undocumented) - allowedOrigins?: string[]; - // @deprecated (undocumented) - enableDnsRebindingProtection?: boolean; -} - -// @public -type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; - -// @public -interface Transport { - close(): Promise; - onclose?: (() => void) | undefined; - onerror?: ((error: Error) => void) | undefined; - onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; - send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; - sessionId?: string | undefined; - setProtocolVersion?: ((version: string) => void) | undefined; - setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; - start(): Promise; -} - -// @public -type TransportSendOptions = { - relatedRequestId?: RequestId | undefined; - resumptionToken?: string | undefined; - onresumptiontoken?: ((token: string) => void) | undefined; -}; - -// @public -type WireOnlyResultKey = 'resultType'; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/server/etc/server.api.md b/packages/server/etc/server.api.md deleted file mode 100644 index 8e76e14025..0000000000 --- a/packages/server/etc/server.api.md +++ /dev/null @@ -1,8793 +0,0 @@ -## API Report File for "@modelcontextprotocol/server" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { JSONSchema } from 'json-schema-typed'; -import * as z from 'zod/v4'; - -// @public -export class AjvJsonSchemaValidator implements jsonSchemaValidator { - constructor(ajv?: AjvLike); - // (undocumented) - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -interface AjvLike { - // (undocumented) - compile: (schema: unknown) => AjvValidateFunction; - // (undocumented) - errorsText: (errors?: any) => string; - // (undocumented) - getSchema: (keyRef: string) => AjvValidateFunction | undefined; -} - -// @public (undocumented) -interface AjvValidateFunction { - // (undocumented) - (input: unknown): boolean; - // (undocumented) - errors?: any; -} - -// @public (undocumented) -export type Annotations = Infer; - -// @public -const AnnotationsSchema: z.ZodObject<{ - audience: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; -}, z.core.$strip>; - -// @public -export type AnyToolHandler = ToolCallback; - -// @public (undocumented) -export type AudioContent = Infer; - -// @public -const AudioContentSchema: z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public -export interface AuthInfo { - clientId: string; - expiresAt?: number; - extra?: Record; - resource?: URL; - scopes: string[]; - token: string; -} - -// @public (undocumented) -type AuthSchemaKey = keyof typeof authSchemas; - -// @public (undocumented) -export type AuthorizationServerMetadata = OAuthMetadata | OpenIdProviderDiscoveryMetadata; - -// @public -export type BaseContext = { - sessionId?: string; - mcpReq: { - id: RequestId; - method: string; - _meta?: RequestMeta; - envelope?: Partial; - inputResponses?: Record; - requestState?: string; - signal: AbortSignal; - send: { - (request: { - method: M; - params?: Record; - }, options?: RequestOptions): Promise; - (request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; - }; - notify: (notification: Notification_2) => Promise; - }; - http?: { - authInfo?: AuthInfo; - }; -}; - -// @public (undocumented) -export type BaseMetadata = Infer; - -// @public -const BaseMetadataSchema: z.ZodObject<{ - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public -const BaseRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; -}, z.core.$strip>; - -// @public (undocumented) -export type BaseToolCallback = Args extends StandardSchemaWithJSON ? (args: StandardSchemaWithJSON.InferOutput, ctx: Ctx) => SendResultT | Promise : (ctx: Ctx) => SendResultT | Promise; - -// @public (undocumented) -export type BlobResourceContents = Infer; - -// @public (undocumented) -const BlobResourceContentsSchema: z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; -}, z.core.$strip>; - -// @public (undocumented) -export type BooleanSchema = Infer; - -// @public -const BooleanSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public -export const CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities"; - -// @public -export const CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo"; - -// @public (undocumented) -const COMPLETABLE_SYMBOL: unique symbol; - -// @public (undocumented) -export type CallToolRequest = Infer; - -// @public (undocumented) -export type CallToolRequestParams = Infer; - -// @public -const CallToolRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - name: z.ZodString; - arguments: z.ZodOptional>; -}, z.core.$strip>; - -// @public -const CallToolRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"tools/call">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - name: z.ZodString; - arguments: z.ZodOptional>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type CallToolResult = StripWireOnly>; - -// @public -const CallToolResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type CancelTaskRequest = Infer; - -// @public @deprecated -const CancelTaskRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"tasks/cancel">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type CancelTaskResult = StripWireOnly>; - -// @public @deprecated -const CancelTaskResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type CancelledNotification = Infer; - -// @public (undocumented) -export type CancelledNotificationParams = Infer; - -// @public (undocumented) -const CancelledNotificationParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - requestId: z.ZodOptional>; - reason: z.ZodOptional; -}, z.core.$strip>; - -// @public -const CancelledNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/cancelled">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - requestId: z.ZodOptional>; - reason: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public -export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { - constructor(options?: { - shortcircuit?: boolean; - draft?: CfWorkerSchemaDraft; - }); - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; - -// @public (undocumented) -export type ClientCapabilities = Infer; - -// @public -const ClientCapabilitiesSchema: z.ZodObject<{ - experimental: z.ZodOptional>>>; - sampling: z.ZodOptional>>; - tools: z.ZodOptional>>; - }, z.core.$strip>>; - elicitation: z.ZodOptional, z.ZodIntersection; - }, z.core.$strip>, z.ZodType>>>; - url: z.ZodOptional>>; - }, z.core.$strip>, z.ZodOptional>>>>>; - roots: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - elicitation: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; -}, z.core.$strip>; - -// @public -export type ClientContext = BaseContext; - -// @public (undocumented) -export type ClientNotification = Infer; - -// @public (undocumented) -const ClientNotificationSchema: z.ZodUnion; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - requestId: z.ZodOptional>; - reason: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/progress">; - params: z.ZodObject<{ - progressToken: z.ZodUnion; - progress: z.ZodNumber; - total: z.ZodOptional; - message: z.ZodOptional; - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/initialized">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/roots/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/tasks/status">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type ClientRequest = Infer; - -// @public (undocumented) -const ClientRequestSchema: z.ZodUnion; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"initialize">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - protocolVersion: z.ZodString; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - sampling: z.ZodOptional>>; - tools: z.ZodOptional>>; - }, z.core.$strip>>; - elicitation: z.ZodOptional, z.ZodIntersection; - }, z.core.$strip>, z.ZodType>>>; - url: z.ZodOptional>>; - }, z.core.$strip>, z.ZodOptional>>>>>; - roots: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - elicitation: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - clientInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"completion/complete">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - ref: z.ZodUnion; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"ref/resource">; - uri: z.ZodString; - }, z.core.$strip>]>; - argument: z.ZodObject<{ - name: z.ZodString; - value: z.ZodString; - }, z.core.$strip>; - context: z.ZodOptional>; - }, z.core.$strip>>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"logging/setLevel">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"prompts/get">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - name: z.ZodString; - arguments: z.ZodOptional>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"prompts/list">; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"resources/list">; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"resources/templates/list">; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"resources/read">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"resources/subscribe">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"resources/unsubscribe">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tools/call">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - name: z.ZodString; - arguments: z.ZodOptional>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"tools/list">; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/get">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/result">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"tasks/list">; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/cancel">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type ClientResult = StripWireOnly>; - -// @public (undocumented) -const ClientResultSchema: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$strict>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - model: z.ZodString; - stopReason: z.ZodOptional, z.ZodString]>>; - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodDiscriminatedUnion<[z.ZodObject<{ - type: z.ZodLiteral<"text">; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - model: z.ZodString; - stopReason: z.ZodOptional, z.ZodString]>>; - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - action: z.ZodEnum<{ - cancel: "cancel"; - accept: "accept"; - decline: "decline"; - }>; - content: z.ZodPipe, z.ZodOptional]>>>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - roots: z.ZodArray; - _meta: z.ZodOptional>; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - tasks: z.ZodArray; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - task: z.ZodObject<{ - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$loose>]>; - -// @public (undocumented) -export type CompatibilityCallToolResult = StripWireOnly>; - -// @public -const CompatibilityCallToolResultSchema: z.ZodUnion<[z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - toolResult: z.ZodUnknown; -}, z.core.$loose>]>; - -// @public (undocumented) -type CompletableMeta = { - complete: CompleteCallback; -}; - -// @public (undocumented) -export type CompletableSchema = T & { - [COMPLETABLE_SYMBOL]: CompletableMeta; -}; - -// @public (undocumented) -export type CompleteCallback = (value: StandardSchemaV1.InferInput, context?: { - arguments?: Record; -}) => StandardSchemaV1.InferInput[] | Promise[]>; - -// @public (undocumented) -export type CompleteRequest = Infer; - -// @public (undocumented) -export type CompleteRequestParams = Infer; - -// @public -const CompleteRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - ref: z.ZodUnion; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"ref/resource">; - uri: z.ZodString; - }, z.core.$strip>]>; - argument: z.ZodObject<{ - name: z.ZodString; - value: z.ZodString; - }, z.core.$strip>; - context: z.ZodOptional>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type CompleteRequestPrompt = ExpandRecursively; - -// @public (undocumented) -export type CompleteRequestResourceTemplate = ExpandRecursively; - -// @public -const CompleteRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"completion/complete">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - ref: z.ZodUnion; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"ref/resource">; - uri: z.ZodString; - }, z.core.$strip>]>; - argument: z.ZodObject<{ - name: z.ZodString; - value: z.ZodString; - }, z.core.$strip>; - context: z.ZodOptional>; - }, z.core.$strip>>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public -export type CompleteResourceTemplateCallback = (value: string, context?: { - arguments?: Record; -}) => string[] | Promise; - -// @public (undocumented) -export type CompleteResult = StripWireOnly>; - -// @public -const CompleteResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - completion: z.ZodObject<{ - values: z.ZodArray; - total: z.ZodOptional; - hasMore: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$loose>; - -// @public (undocumented) -export type ContentBlock = Infer; - -// @public -const ContentBlockSchema: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type CreateMessageRequest = Infer; - -// @public (undocumented) -export type CreateMessageRequestParams = Infer; - -// @public -export type CreateMessageRequestParamsBase = Omit; - -// @public -const CreateMessageRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - messages: z.ZodArray; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; - _meta: z.ZodOptional>; - }, z.core.$strip>>; - modelPreferences: z.ZodOptional; - }, z.core.$strip>>>; - costPriority: z.ZodOptional; - speedPriority: z.ZodOptional; - intelligencePriority: z.ZodOptional; - }, z.core.$strip>>; - systemPrompt: z.ZodOptional; - includeContext: z.ZodOptional>; - temperature: z.ZodOptional; - maxTokens: z.ZodNumber; - stopSequences: z.ZodOptional>; - metadata: z.ZodOptional>>; - tools: z.ZodOptional; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>>; - toolChoice: z.ZodOptional>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public -export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams { - // (undocumented) - tools: Tool[]; -} - -// @public -const CreateMessageRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"sampling/createMessage">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - messages: z.ZodArray; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; - _meta: z.ZodOptional>; - }, z.core.$strip>>; - modelPreferences: z.ZodOptional; - }, z.core.$strip>>>; - costPriority: z.ZodOptional; - speedPriority: z.ZodOptional; - intelligencePriority: z.ZodOptional; - }, z.core.$strip>>; - systemPrompt: z.ZodOptional; - includeContext: z.ZodOptional>; - temperature: z.ZodOptional; - maxTokens: z.ZodNumber; - stopSequences: z.ZodOptional>; - metadata: z.ZodOptional>>; - tools: z.ZodOptional; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>>; - toolChoice: z.ZodOptional>; - }, z.core.$strip>>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type CreateMessageResult = StripWireOnly>; - -// @public -const CreateMessageResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - model: z.ZodString; - stopReason: z.ZodOptional, z.ZodString]>>; - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodDiscriminatedUnion<[z.ZodObject<{ - type: z.ZodLiteral<"text">; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">; -}, z.core.$loose>; - -// @public (undocumented) -export type CreateMessageResultWithTools = StripWireOnly>; - -// @public -const CreateMessageResultWithToolsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - model: z.ZodString; - stopReason: z.ZodOptional, z.ZodString]>>; - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type CreateTaskResult = StripWireOnly>; - -// @public @deprecated -const CreateTaskResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - task: z.ZodObject<{ - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$loose>; - -// @public (undocumented) -export type Cursor = Infer; - -// @public -const CursorSchema: z.ZodString; - -// @public (undocumented) -export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; - -// @public -export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60000; - -// @public (undocumented) -export type DiscoverRequest = Infer; - -// @public -const DiscoverRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"server/discover">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type DiscoverResult = StripWireOnly>; - -// @public -const DiscoverResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - supportedVersions: z.ZodArray; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - logging: z.ZodOptional>>; - completions: z.ZodOptional>>; - prompts: z.ZodOptional; - }, z.core.$strip>>; - resources: z.ZodOptional; - listChanged: z.ZodOptional; - }, z.core.$strip>>; - tools: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - serverInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - instructions: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export type ElicitRequest = Infer; - -// @public (undocumented) -export type ElicitRequestFormParams = Infer; - -// @public -const ElicitRequestFormParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodOptional>; - message: z.ZodString; - requestedSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodRecord; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; - }, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>]>]>, z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>]>>; - required: z.ZodOptional>; - }, z.core.$catchall>; -}, z.core.$strip>; - -// @public (undocumented) -export type ElicitRequestParams = Infer; - -// @public -const ElicitRequestParamsSchema: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodOptional>; - message: z.ZodString; - requestedSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodRecord; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; - }, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>]>]>, z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>]>>; - required: z.ZodOptional>; - }, z.core.$catchall>; -}, z.core.$strip>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodLiteral<"url">; - message: z.ZodString; - elicitationId: z.ZodString; - url: z.ZodString; -}, z.core.$strip>]>; - -// @public -const ElicitRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"elicitation/create">; - params: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodOptional>; - message: z.ZodString; - requestedSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodRecord; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; - }, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>]>]>, z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>]>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - }, z.core.$strip>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodLiteral<"url">; - message: z.ZodString; - elicitationId: z.ZodString; - url: z.ZodString; - }, z.core.$strip>]>; -}, z.core.$strip>; - -// @public (undocumented) -export type ElicitRequestURLParams = Infer; - -// @public -const ElicitRequestURLParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodLiteral<"url">; - message: z.ZodString; - elicitationId: z.ZodString; - url: z.ZodString; -}, z.core.$strip>; - -// @public (undocumented) -export type ElicitResult = StripWireOnly>; - -// @public -const ElicitResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - action: z.ZodEnum<{ - cancel: "cancel"; - accept: "accept"; - decline: "decline"; - }>; - content: z.ZodPipe, z.ZodOptional]>>>>; -}, z.core.$loose>; - -// @public (undocumented) -export type ElicitationCompleteNotification = Infer; - -// @public (undocumented) -export type ElicitationCompleteNotificationParams = Infer; - -// @public -const ElicitationCompleteNotificationParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - elicitationId: z.ZodString; -}, z.core.$strip>; - -// @public -const ElicitationCompleteNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/elicitation/complete">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - elicitationId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type EmbeddedResource = Infer; - -// @public -const EmbeddedResourceSchema: z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type EmptyResult = StripWireOnly>; - -// @public -const EmptyResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$strict>; - -// @public (undocumented) -export type EnumSchema = Infer; - -// @public -const EnumSchemaSchema: z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; -}, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>]>]>; - -// @public (undocumented) -export type EventId = string; - -// @public -export interface EventStore { - getStreamIdForEventId?(eventId: EventId): Promise; - // (undocumented) - replayEventsAfter(lastEventId: EventId, input: { - send: (eventId: EventId, message: JSONRPCMessage) => Promise; - }): Promise; - storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; -} - -// @public -type ExpandRecursively = T extends object ? (T extends infer O ? { [K in keyof O]: ExpandRecursively } : never) : T; - -// @public (undocumented) -export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; - -// @public (undocumented) -type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; - -// @public (undocumented) -export type GetPromptRequest = Infer; - -// @public (undocumented) -export type GetPromptRequestParams = Infer; - -// @public -const GetPromptRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - name: z.ZodString; - arguments: z.ZodOptional>; -}, z.core.$strip>; - -// @public -const GetPromptRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"prompts/get">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - name: z.ZodString; - arguments: z.ZodOptional>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type GetPromptResult = StripWireOnly>; - -// @public -const GetPromptResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - description: z.ZodOptional; - messages: z.ZodArray; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type GetTaskPayloadRequest = Infer; - -// @public @deprecated -const GetTaskPayloadRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"tasks/result">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type GetTaskPayloadResult = StripWireOnly>; - -// @public @deprecated -const GetTaskPayloadResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type GetTaskRequest = Infer; - -// @public @deprecated -const GetTaskRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"tasks/get">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type GetTaskResult = StripWireOnly>; - -// @public @deprecated -const GetTaskResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] }; - -// @public -export interface HandleRequestOptions { - authInfo?: AuthInfo; - parsedBody?: unknown; -} - -// @public (undocumented) -export type HostHeaderValidationResult = { - ok: true; - hostname: string; -} | { - ok: false; - errorCode: 'missing_host' | 'invalid_host_header' | 'invalid_host'; - message: string; - hostHeader?: string; - hostname?: string; -}; - -// @public (undocumented) -export const INTERNAL_ERROR = -32603; - -// @public (undocumented) -export const INVALID_PARAMS = -32602; - -// @public (undocumented) -export const INVALID_REQUEST = -32600; - -// @public (undocumented) -export type Icon = Infer; - -// @public -const IconSchema: z.ZodObject<{ - src: z.ZodString; - mimeType: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type Icons = Infer; - -// @public -const IconsSchema: z.ZodObject<{ - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; -}, z.core.$strip>; - -// @public (undocumented) -export type ImageContent = Infer; - -// @public -const ImageContentSchema: z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type Implementation = Infer; - -// @public -const ImplementationSchema: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public -export class InMemoryTransport implements Transport { - // (undocumented) - close(): Promise; - static createLinkedPair(): [InMemoryTransport, InMemoryTransport]; - // (undocumented) - onclose?: () => void; - // (undocumented) - onerror?: (error: Error) => void; - // (undocumented) - onmessage?: (message: JSONRPCMessage, extra?: { - authInfo?: AuthInfo; - }) => void; - send(message: JSONRPCMessage, options?: { - relatedRequestId?: RequestId; - authInfo?: AuthInfo; - }): Promise; - // (undocumented) - sessionId?: string; - // (undocumented) - start(): Promise; -} - -// @public (undocumented) -type Infer = Flatten>; - -// @public (undocumented) -type InferHandlerResult = R extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : Result; - -// @public -type InferRawShape = z.infer>; - -// @public (undocumented) -export type InitializeRequest = Infer; - -// @public (undocumented) -export type InitializeRequestParams = Infer; - -// @public (undocumented) -const InitializeRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - protocolVersion: z.ZodString; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - sampling: z.ZodOptional>>; - tools: z.ZodOptional>>; - }, z.core.$strip>>; - elicitation: z.ZodOptional, z.ZodIntersection; - }, z.core.$strip>, z.ZodType>>>; - url: z.ZodOptional>>; - }, z.core.$strip>, z.ZodOptional>>>>>; - roots: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - elicitation: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - clientInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public -const InitializeRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"initialize">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - protocolVersion: z.ZodString; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - sampling: z.ZodOptional>>; - tools: z.ZodOptional>>; - }, z.core.$strip>>; - elicitation: z.ZodOptional, z.ZodIntersection; - }, z.core.$strip>, z.ZodType>>>; - url: z.ZodOptional>>; - }, z.core.$strip>, z.ZodOptional>>>>>; - roots: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - elicitation: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - clientInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type InitializeResult = StripWireOnly>; - -// @public -const InitializeResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - protocolVersion: z.ZodString; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - logging: z.ZodOptional>>; - completions: z.ZodOptional>>; - prompts: z.ZodOptional; - }, z.core.$strip>>; - resources: z.ZodOptional; - listChanged: z.ZodOptional; - }, z.core.$strip>>; - tools: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - serverInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - instructions: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export type InitializedNotification = Infer; - -// @public -const InitializedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/initialized">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export interface InternalError extends JSONRPCErrorObject { - // (undocumented) - code: typeof INTERNAL_ERROR; -} - -// @public (undocumented) -export interface InvalidParamsError extends JSONRPCErrorObject { - // (undocumented) - code: typeof INVALID_PARAMS; -} - -// @public (undocumented) -export interface InvalidRequestError extends JSONRPCErrorObject { - // (undocumented) - code: typeof INVALID_REQUEST; -} - -// @public (undocumented) -export type JSONArray = JSONValue[]; - -// @public (undocumented) -export type JSONObject = { - [key: string]: JSONValue; -}; - -// @public (undocumented) -type JSONRPCErrorObject = { - code: number; - message: string; - data?: unknown; -}; - -// @public (undocumented) -export type JSONRPCErrorResponse = Infer; - -// @public -const JSONRPCErrorResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>; - -// @public (undocumented) -export type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; - -// @public (undocumented) -export type JSONRPCNotification = Infer; - -// @public -const JSONRPCNotificationSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>; - -// @public (undocumented) -export type JSONRPCRequest = Infer; - -// @public -const JSONRPCRequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>; - -// @public (undocumented) -export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; - -// @public (undocumented) -export type JSONRPCResultResponse = Omit, 'result'> & { - result: Result; -}; - -// @public -const JSONRPCResultResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>; - -// @public (undocumented) -export const JSONRPC_VERSION = "2.0"; - -// @public (undocumented) -export type JSONValue = string | number | boolean | null | JSONObject | JSONArray; - -// @public -export type JsonSchemaType = JSONSchema.Interface; - -// @public -export type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -export type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public (undocumented) -export const LATEST_PROTOCOL_VERSION = "2025-11-25"; - -// @public @deprecated -export const LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel"; - -// @public -type LegacyPromptCallback = Args extends ZodRawShape ? (args: InferRawShape, ctx: ServerContext) => GetPromptResult | Promise : (ctx: ServerContext) => GetPromptResult | Promise; - -// @public (undocumented) -export type LegacyTitledEnumSchema = Infer; - -// @public -const LegacyTitledEnumSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public -type LegacyToolCallback = Args extends ZodRawShape ? (args: InferRawShape, ctx: ServerContext) => CallToolResult | Promise : (ctx: ServerContext) => CallToolResult | Promise; - -// @public -export type ListChangedCallback = (error: Error | null, items: T[] | null) => void; - -// @public -export type ListChangedHandlers = { - tools?: ListChangedOptions; - prompts?: ListChangedOptions; - resources?: ListChangedOptions; -}; - -// @public -export type ListChangedOptions = { - autoRefresh?: boolean; - debounceMs?: number; - onChanged: ListChangedCallback; -}; - -// @public (undocumented) -export type ListPromptsRequest = Infer; - -// @public -const ListPromptsRequestSchema: z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"prompts/list">; -}, z.core.$strip>; - -// @public (undocumented) -export type ListPromptsResult = StripWireOnly>; - -// @public -const ListPromptsResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - prompts: z.ZodArray; - arguments: z.ZodOptional; - required: z.ZodOptional; - }, z.core.$strip>>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public (undocumented) -export type ListResourceTemplatesRequest = Infer; - -// @public -const ListResourceTemplatesRequestSchema: z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"resources/templates/list">; -}, z.core.$strip>; - -// @public (undocumented) -export type ListResourceTemplatesResult = StripWireOnly>; - -// @public -const ListResourceTemplatesResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - resourceTemplates: z.ZodArray; - mimeType: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public -export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult | Promise; - -// @public (undocumented) -export type ListResourcesRequest = Infer; - -// @public -const ListResourcesRequestSchema: z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"resources/list">; -}, z.core.$strip>; - -// @public (undocumented) -export type ListResourcesResult = StripWireOnly>; - -// @public -const ListResourcesResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - resources: z.ZodArray; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public (undocumented) -export type ListRootsRequest = Infer; - -// @public -const ListRootsRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"roots/list">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type ListRootsResult = StripWireOnly>; - -// @public -const ListRootsResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - roots: z.ZodArray; - _meta: z.ZodOptional>; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type ListTasksRequest = Infer; - -// @public @deprecated -const ListTasksRequestSchema: z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"tasks/list">; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type ListTasksResult = StripWireOnly>; - -// @public @deprecated -const ListTasksResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - tasks: z.ZodArray; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public (undocumented) -export type ListToolsRequest = Infer; - -// @public -const ListToolsRequestSchema: z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"tools/list">; -}, z.core.$strip>; - -// @public (undocumented) -export type ListToolsResult = StripWireOnly>; - -// @public -const ListToolsResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - tools: z.ZodArray; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>; - -// @public (undocumented) -export type LoggingLevel = Infer; - -// @public -const LoggingLevelSchema: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; -}>; - -// @public (undocumented) -export type LoggingMessageNotification = Infer; - -// @public (undocumented) -export type LoggingMessageNotificationParams = Infer; - -// @public -const LoggingMessageNotificationParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; - logger: z.ZodOptional; - data: z.ZodUnknown; -}, z.core.$strip>; - -// @public -const LoggingMessageNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/message">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; - logger: z.ZodOptional; - data: z.ZodUnknown; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export const METHOD_NOT_FOUND = -32601; - -// @public -export class McpServer { - constructor(serverInfo: Implementation, options?: ServerOptions); - close(): Promise; - connect(transport: Transport): Promise; - isConnected(): boolean; - registerPrompt(name: string, config: { - title?: string; - description?: string; - argsSchema?: Args; - _meta?: Record; - }, cb: PromptCallback): RegisteredPrompt; - // @deprecated (undocumented) - registerPrompt(name: string, config: { - title?: string; - description?: string; - argsSchema?: Args; - _meta?: Record; - }, cb: LegacyPromptCallback): RegisteredPrompt; - registerResource(name: string, uriOrTemplate: string, config: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; - // (undocumented) - registerResource(name: string, uriOrTemplate: ResourceTemplate, config: ResourceMetadata, readCallback: ReadResourceTemplateCallback): RegisteredResourceTemplate; - registerTool(name: string, config: { - title?: string; - description?: string; - inputSchema?: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - _meta?: Record; - }, cb: ToolCallback): RegisteredTool; - // @deprecated (undocumented) - registerTool(name: string, config: { - title?: string; - description?: string; - inputSchema?: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - _meta?: Record; - }, cb: LegacyToolCallback): RegisteredTool; - sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string): Promise; - sendPromptListChanged(): void; - sendResourceListChanged(): void; - sendToolListChanged(): void; - readonly server: Server; -} - -// @public -export interface MessageExtraInfo { - authInfo?: AuthInfo; - closeSSEStream?: () => void; - closeStandaloneSSEStream?: () => void; - request?: globalThis.Request; -} - -// @public (undocumented) -export type MetaObject = Record; - -// @public (undocumented) -export interface MethodNotFoundError extends JSONRPCErrorObject { - // (undocumented) - code: typeof METHOD_NOT_FOUND; -} - -// @public (undocumented) -type MethodToTypeMap = { [T in U as T extends { - method: infer M extends string; - } ? M : never]: T }; - -// @public (undocumented) -export type ModelHint = Infer; - -// @public -const ModelHintSchema: z.ZodObject<{ - name: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ModelPreferences = Infer; - -// @public -const ModelPreferencesSchema: z.ZodObject<{ - hints: z.ZodOptional; - }, z.core.$strip>>>; - costPriority: z.ZodOptional; - speedPriority: z.ZodOptional; - intelligencePriority: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type MultiSelectEnumSchema = Infer; - -// @public -const MultiSelectEnumSchemaSchema: z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type NotificationMethod = Exclude; - -// @public -type NotificationOptions_2 = { - relatedRequestId?: RequestId; -}; -export { NotificationOptions_2 as NotificationOptions } - -// @public (undocumented) -export type NotificationParams = Infer; - -// @public (undocumented) -const NotificationSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; -}, z.core.$strip>; - -// @public (undocumented) -export type NotificationTypeMap = MethodToTypeMap>; - -// @public (undocumented) -type Notification_2 = Infer; -export { Notification_2 as Notification } - -// @public (undocumented) -const NotificationsParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; -}, z.core.$strip>; - -// @public (undocumented) -export type NumberSchema = Infer; - -// @public -const NumberSchemaSchema: z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthClientInformation = z.infer; - -// @public (undocumented) -export type OAuthClientInformationFull = z.infer; - -// @public -const OAuthClientInformationFullSchema: z.ZodObject<{ - redirect_uris: z.ZodArray; - token_endpoint_auth_method: z.ZodOptional; - grant_types: z.ZodOptional>; - response_types: z.ZodOptional>; - client_name: z.ZodOptional; - client_uri: z.ZodOptional; - logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - scope: z.ZodOptional; - contacts: z.ZodOptional>; - tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - policy_uri: z.ZodOptional; - jwks_uri: z.ZodOptional; - jwks: z.ZodOptional; - software_id: z.ZodOptional; - software_version: z.ZodOptional; - software_statement: z.ZodOptional; - client_id: z.ZodString; - client_secret: z.ZodOptional; - client_id_issued_at: z.ZodOptional; - client_secret_expires_at: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthClientInformationMixed = OAuthClientInformation | OAuthClientInformationFull; - -// @public -const OAuthClientInformationSchema: z.ZodObject<{ - client_id: z.ZodString; - client_secret: z.ZodOptional; - client_id_issued_at: z.ZodOptional; - client_secret_expires_at: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthClientMetadata = z.infer; - -// @public -const OAuthClientMetadataSchema: z.ZodObject<{ - redirect_uris: z.ZodArray; - token_endpoint_auth_method: z.ZodOptional; - grant_types: z.ZodOptional>; - response_types: z.ZodOptional>; - client_name: z.ZodOptional; - client_uri: z.ZodOptional; - logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - scope: z.ZodOptional; - contacts: z.ZodOptional>; - tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - policy_uri: z.ZodOptional; - jwks_uri: z.ZodOptional; - jwks: z.ZodOptional; - software_id: z.ZodOptional; - software_version: z.ZodOptional; - software_statement: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthClientRegistrationError = z.infer; - -// @public -const OAuthClientRegistrationErrorSchema: z.ZodObject<{ - error: z.ZodString; - error_description: z.ZodOptional; -}, z.core.$strip>; - -// @public -export class OAuthError extends Error { - constructor(code: OAuthErrorCode | string, message: string, errorUri?: string | undefined); - // (undocumented) - readonly code: OAuthErrorCode | string; - // (undocumented) - readonly errorUri?: string | undefined; - static fromResponse(response: OAuthErrorResponse): OAuthError; - toResponseObject(): OAuthErrorResponse; -} - -// @public -export enum OAuthErrorCode { - AccessDenied = "access_denied", - InsufficientScope = "insufficient_scope", - InvalidClient = "invalid_client", - InvalidClientMetadata = "invalid_client_metadata", - InvalidGrant = "invalid_grant", - InvalidRequest = "invalid_request", - InvalidScope = "invalid_scope", - InvalidTarget = "invalid_target", - InvalidToken = "invalid_token", - MethodNotAllowed = "method_not_allowed", - ServerError = "server_error", - TemporarilyUnavailable = "temporarily_unavailable", - TooManyRequests = "too_many_requests", - UnauthorizedClient = "unauthorized_client", - UnsupportedGrantType = "unsupported_grant_type", - UnsupportedResponseType = "unsupported_response_type", - UnsupportedTokenType = "unsupported_token_type", -} - -// @public (undocumented) -export type OAuthErrorResponse = z.infer; - -// @public -const OAuthErrorResponseSchema: z.ZodObject<{ - error: z.ZodString; - error_description: z.ZodOptional; - error_uri: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthMetadata = z.infer; - -// @public -const OAuthMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - revocation_endpoint: z.ZodOptional; - revocation_endpoint_auth_methods_supported: z.ZodOptional>; - revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - introspection_endpoint: z.ZodOptional; - introspection_endpoint_auth_methods_supported: z.ZodOptional>; - introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - code_challenge_methods_supported: z.ZodOptional>; - client_id_metadata_document_supported: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export type OAuthProtectedResourceMetadata = z.infer; - -// @public -const OAuthProtectedResourceMetadataSchema: z.ZodObject<{ - resource: z.ZodString; - authorization_servers: z.ZodOptional>; - jwks_uri: z.ZodOptional; - scopes_supported: z.ZodOptional>; - bearer_methods_supported: z.ZodOptional>; - resource_signing_alg_values_supported: z.ZodOptional>; - resource_name: z.ZodOptional; - resource_documentation: z.ZodOptional; - resource_policy_uri: z.ZodOptional; - resource_tos_uri: z.ZodOptional; - tls_client_certificate_bound_access_tokens: z.ZodOptional; - authorization_details_types_supported: z.ZodOptional>; - dpop_signing_alg_values_supported: z.ZodOptional>; - dpop_bound_access_tokens_required: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export type OAuthTokenRevocationRequest = z.infer; - -// @public -const OAuthTokenRevocationRequestSchema: z.ZodObject<{ - token: z.ZodString; - token_type_hint: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OAuthTokens = z.infer; - -// @public -const OAuthTokensSchema: z.ZodObject<{ - access_token: z.ZodString; - id_token: z.ZodOptional; - token_type: z.ZodString; - expires_in: z.ZodOptional>; - scope: z.ZodOptional; - refresh_token: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OpenIdProviderDiscoveryMetadata = z.infer; - -// @public -const OpenIdProviderDiscoveryMetadataSchema: z.ZodObject<{ - code_challenge_methods_supported: z.ZodOptional>; - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - userinfo_endpoint: z.ZodOptional; - jwks_uri: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - acr_values_supported: z.ZodOptional>; - subject_types_supported: z.ZodArray; - id_token_signing_alg_values_supported: z.ZodArray; - id_token_encryption_alg_values_supported: z.ZodOptional>; - id_token_encryption_enc_values_supported: z.ZodOptional>; - userinfo_signing_alg_values_supported: z.ZodOptional>; - userinfo_encryption_alg_values_supported: z.ZodOptional>; - userinfo_encryption_enc_values_supported: z.ZodOptional>; - request_object_signing_alg_values_supported: z.ZodOptional>; - request_object_encryption_alg_values_supported: z.ZodOptional>; - request_object_encryption_enc_values_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - display_values_supported: z.ZodOptional>; - claim_types_supported: z.ZodOptional>; - claims_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - claims_locales_supported: z.ZodOptional>; - ui_locales_supported: z.ZodOptional>; - claims_parameter_supported: z.ZodOptional; - request_parameter_supported: z.ZodOptional; - request_uri_parameter_supported: z.ZodOptional; - require_request_uri_registration: z.ZodOptional; - op_policy_uri: z.ZodOptional; - op_tos_uri: z.ZodOptional; - client_id_metadata_document_supported: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type OpenIdProviderMetadata = z.infer; - -// @public -const OpenIdProviderMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - userinfo_endpoint: z.ZodOptional; - jwks_uri: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - acr_values_supported: z.ZodOptional>; - subject_types_supported: z.ZodArray; - id_token_signing_alg_values_supported: z.ZodArray; - id_token_encryption_alg_values_supported: z.ZodOptional>; - id_token_encryption_enc_values_supported: z.ZodOptional>; - userinfo_signing_alg_values_supported: z.ZodOptional>; - userinfo_encryption_alg_values_supported: z.ZodOptional>; - userinfo_encryption_enc_values_supported: z.ZodOptional>; - request_object_signing_alg_values_supported: z.ZodOptional>; - request_object_encryption_alg_values_supported: z.ZodOptional>; - request_object_encryption_enc_values_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - display_values_supported: z.ZodOptional>; - claim_types_supported: z.ZodOptional>; - claims_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - claims_locales_supported: z.ZodOptional>; - ui_locales_supported: z.ZodOptional>; - claims_parameter_supported: z.ZodOptional; - request_parameter_supported: z.ZodOptional; - request_uri_parameter_supported: z.ZodOptional; - require_request_uri_registration: z.ZodOptional; - op_policy_uri: z.ZodOptional; - op_tos_uri: z.ZodOptional; - client_id_metadata_document_supported: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export const PARSE_ERROR = -32700; - -// @public -export const PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"; - -// @public (undocumented) -export type PaginatedRequest = Infer; - -// @public (undocumented) -export type PaginatedRequestParams = Infer; - -// @public (undocumented) -const PaginatedRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -const PaginatedRequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type PaginatedResult = StripWireOnly>; - -// @public (undocumented) -const PaginatedResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export interface ParseError extends JSONRPCErrorObject { - // (undocumented) - code: typeof PARSE_ERROR; -} - -// @public (undocumented) -export type PingRequest = Infer; - -// @public -const PingRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"ping">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -type Primitive = string | number | boolean | bigint | null | undefined; - -// @public (undocumented) -export type PrimitiveSchemaDefinition = Infer; - -// @public -const PrimitiveSchemaDefinitionSchema: z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; -}, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>]>]>, z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; -}, z.core.$strip>]>; - -// @public (undocumented) -export type Progress = Infer; - -// @public -export type ProgressCallback = (progress: Progress) => void; - -// @public (undocumented) -export type ProgressNotification = Infer; - -// @public (undocumented) -export type ProgressNotificationParams = Infer; - -// @public (undocumented) -const ProgressNotificationParamsSchema: z.ZodObject<{ - progressToken: z.ZodUnion; - progress: z.ZodNumber; - total: z.ZodOptional; - message: z.ZodOptional; - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; -}, z.core.$strip>; - -// @public -const ProgressNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/progress">; - params: z.ZodObject<{ - progressToken: z.ZodUnion; - progress: z.ZodNumber; - total: z.ZodOptional; - message: z.ZodOptional; - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -const ProgressSchema: z.ZodObject<{ - progress: z.ZodNumber; - total: z.ZodOptional; - message: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ProgressToken = Infer; - -// @public -const ProgressTokenSchema: z.ZodUnion; - -// @public (undocumented) -export type Prompt = Infer; - -// @public (undocumented) -export type PromptArgument = Infer; - -// @public -const PromptArgumentSchema: z.ZodObject<{ - name: z.ZodString; - description: z.ZodOptional; - required: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type PromptCallback = Args extends StandardSchemaWithJSON ? (args: StandardSchemaWithJSON.InferOutput, ctx: ServerContext) => GetPromptResult | Promise : (ctx: ServerContext) => GetPromptResult | Promise; - -// @public -type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; - -// @public (undocumented) -export type PromptListChangedNotification = Infer; - -// @public -const PromptListChangedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/prompts/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type PromptMessage = Infer; - -// @public -const PromptMessageSchema: z.ZodObject<{ - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>; -}, z.core.$strip>; - -// @public (undocumented) -export type PromptReference = Infer; - -// @public -const PromptReferenceSchema: z.ZodObject<{ - type: z.ZodLiteral<"ref/prompt">; - name: z.ZodString; -}, z.core.$strip>; - -// @public -const PromptSchema: z.ZodObject<{ - description: z.ZodOptional; - arguments: z.ZodOptional; - required: z.ZodOptional; - }, z.core.$strip>>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public -abstract class Protocol { - constructor(_options?: ProtocolOptions | undefined); - assertCanSetRequestHandler(method: RequestMethod | string): void; - protected abstract assertCapabilityForMethod(method: RequestMethod | string): void; - protected abstract assertNotificationCapability(method: NotificationMethod | string): void; - protected abstract assertRequestHandlerCapability(method: string): void; - protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; - close(): Promise; - connect(transport: Transport): Promise; - fallbackNotificationHandler?: (notification: Notification_2) => Promise; - fallbackRequestHandler?: (request: JSONRPCRequest, ctx: ContextT) => Promise; - notification(notification: Notification_2, options?: NotificationOptions_2): Promise; - onclose?: () => void; - onerror?: (error: Error) => void; - removeNotificationHandler(method: NotificationMethod | string): void; - removeRequestHandler(method: RequestMethod | string): void; - request(request: { - method: M; - params?: Record; - }, options?: RequestOptions): Promise; - // (undocumented) - request(request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; - protected _requestWithSchema(request: Request_2, resultSchema: T, options?: RequestOptions): Promise>; - setNotificationHandler(method: M, handler: (notification: NotificationTypeMap[M]) => void | Promise): void; - // (undocumented) - setNotificationHandler

(method: string, schemas: { - params: P; - }, handler: (params: StandardSchemaV1.InferOutput

, notification: Notification_2) => void | Promise): void; - setRequestHandler(method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => ResultTypeMap[M] | Promise): void; - // (undocumented) - setRequestHandler

(method: string, schemas: { - params: P; - result?: R; - }, handler: (params: StandardSchemaV1.InferOutput

, ctx: ContextT) => InferHandlerResult | Promise>): void; - // (undocumented) - protected _supportedProtocolVersions: string[]; - // (undocumented) - get transport(): Transport | undefined; - protected _wrapHandler(_method: string, handler: (request: JSONRPCRequest, ctx: ContextT) => Promise): (request: JSONRPCRequest, ctx: ContextT) => Promise; -} - -// @public -export class ProtocolError extends Error { - constructor(code: number, message: string, data?: unknown | undefined); - // (undocumented) - readonly code: number; - // (undocumented) - readonly data?: unknown | undefined; - static fromError(code: number, message: string, data?: unknown): ProtocolError; -} - -// @public -export enum ProtocolErrorCode { - // (undocumented) - InternalError = -32603, - // (undocumented) - InvalidParams = -32602, - // (undocumented) - InvalidRequest = -32600, - // (undocumented) - MethodNotFound = -32601, - MissingRequiredClientCapability = -32003, - // (undocumented) - ParseError = -32700, - // (undocumented) - ResourceNotFound = -32002, - UnsupportedProtocolVersion = -32004, - // (undocumented) - UrlElicitationRequired = -32042, -} - -// @public -export type ProtocolOptions = { - supportedProtocolVersions?: string[]; - enforceStrictCapabilities?: boolean; - debouncedNotificationMethods?: string[]; -}; - -// @public (undocumented) -type ProtocolSchemaKey = (typeof SPEC_SCHEMA_KEYS)[number]; - -// @public @deprecated -export const RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; - -// @public -export class ReadBuffer { - constructor(options?: { - maxBufferSize?: number; - }); - // (undocumented) - append(chunk: Buffer): void; - // (undocumented) - clear(): void; - // (undocumented) - readMessage(): JSONRPCMessage | null; -} - -// @public -export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; - -// @public (undocumented) -export type ReadResourceRequest = Infer; - -// @public (undocumented) -export type ReadResourceRequestParams = Infer; - -// @public -const ReadResourceRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const ReadResourceRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"resources/read">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type ReadResourceResult = StripWireOnly>; - -// @public -const ReadResourceResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - contents: z.ZodArray; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>>; -}, z.core.$loose>; - -// @public -export type ReadResourceTemplateCallback = (uri: URL, variables: Variables, ctx: ServerContext) => ReadResourceResult | Promise; - -// @public (undocumented) -export type RegisteredPrompt = { - title?: string; - description?: string; - argsSchema?: StandardSchemaWithJSON; - _meta?: Record; - handler: PromptHandler; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - description?: string; - argsSchema?: Args; - _meta?: Record; - callback?: PromptCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -// @public (undocumented) -export type RegisteredResource = { - name: string; - title?: string; - metadata?: ResourceMetadata; - readCallback: ReadResourceCallback; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string; - title?: string; - uri?: string | null; - metadata?: ResourceMetadata; - callback?: ReadResourceCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -// @public (undocumented) -export type RegisteredResourceTemplate = { - resourceTemplate: ResourceTemplate; - title?: string; - metadata?: ResourceMetadata; - readCallback: ReadResourceTemplateCallback; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - template?: ResourceTemplate; - metadata?: ResourceMetadata; - callback?: ReadResourceTemplateCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -// @public (undocumented) -export type RegisteredTool = { - title?: string; - description?: string; - inputSchema?: StandardSchemaWithJSON; - outputSchema?: StandardSchemaWithJSON; - annotations?: ToolAnnotations; - execution?: ToolExecution; - _meta?: Record; - handler: AnyToolHandler; - executor: ToolExecutor; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - description?: string; - paramsSchema?: StandardSchemaWithJSON; - outputSchema?: StandardSchemaWithJSON; - annotations?: ToolAnnotations; - _meta?: Record; - callback?: ToolCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -// @public @deprecated (undocumented) -export type RelatedTaskMetadata = Infer; - -// @public @deprecated -const RelatedTaskMetadataSchema: z.ZodObject<{ - taskId: z.ZodString; -}, z.core.$strip>; - -// @public -export interface RequestHandlerSchemas

{ - // (undocumented) - params: P; - // (undocumented) - result?: R; -} - -// @public (undocumented) -export type RequestId = Infer; - -// @public -const RequestIdSchema: z.ZodUnion; - -// @public (undocumented) -export type RequestMeta = Infer; - -// @public -export type RequestMetaEnvelope = Infer; - -// @public -const RequestMetaEnvelopeSchema: z.ZodObject<{ - progressToken: z.ZodOptional>; - "io.modelcontextprotocol/protocolVersion": z.ZodString; - "io.modelcontextprotocol/clientInfo": z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - "io.modelcontextprotocol/clientCapabilities": z.ZodObject<{ - experimental: z.ZodOptional>>>; - sampling: z.ZodOptional>>; - tools: z.ZodOptional>>; - }, z.core.$strip>>; - elicitation: z.ZodOptional, z.ZodIntersection; - }, z.core.$strip>, z.ZodType>>>; - url: z.ZodOptional>>; - }, z.core.$strip>, z.ZodOptional>>>>>; - roots: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - elicitation: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - "io.modelcontextprotocol/logLevel": z.ZodOptional>; -}, z.core.$loose>; - -// @public (undocumented) -export type RequestMetaObject = RequestMeta; - -// @public (undocumented) -const RequestMetaSchema: z.ZodObject<{ - progressToken: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; -}, z.core.$loose>; - -// @public (undocumented) -export type RequestMethod = Exclude; - -// @public -export type RequestOptions = { - onprogress?: ProgressCallback; - signal?: AbortSignal; - timeout?: number; - resetTimeoutOnProgress?: boolean; - maxTotalTimeout?: number; -} & TransportSendOptions; - -// @public (undocumented) -export type RequestParams = Infer; - -// @public (undocumented) -const RequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; -}, z.core.$strip>; - -// @public (undocumented) -export type RequestTypeMap = MethodToTypeMap>; - -// @public (undocumented) -type Request_2 = Infer; -export { Request_2 as Request } - -// @public (undocumented) -export type Resource = Infer; - -// @public (undocumented) -export type ResourceContents = Infer; - -// @public -const ResourceContentsSchema: z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type ResourceLink = Infer; - -// @public -const ResourceLinkSchema: z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; -}, z.core.$strip>; - -// @public (undocumented) -export type ResourceListChangedNotification = Infer; - -// @public -const ResourceListChangedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/resources/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public -export type ResourceMetadata = Omit; - -// @public (undocumented) -export type ResourceRequestParams = Infer; - -// @public (undocumented) -const ResourceRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const ResourceSchema: z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public -export class ResourceTemplate { - constructor(uriTemplate: string | UriTemplate, _callbacks: { - list: ListResourcesCallback | undefined; - complete?: { - [variable: string]: CompleteResourceTemplateCallback; - }; - }); - completeCallback(variable: string): CompleteResourceTemplateCallback | undefined; - get listCallback(): ListResourcesCallback | undefined; - get uriTemplate(): UriTemplate; -} - -// @public (undocumented) -export type ResourceTemplateReference = Infer; - -// @public -const ResourceTemplateReferenceSchema: z.ZodObject<{ - type: z.ZodLiteral<"ref/resource">; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const ResourceTemplateSchema: z.ZodObject<{ - uriTemplate: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ResourceTemplateType = Infer; - -// @public (undocumented) -export type ResourceUpdatedNotification = Infer; - -// @public (undocumented) -export type ResourceUpdatedNotificationParams = Infer; - -// @public -const ResourceUpdatedNotificationParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const ResourceUpdatedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/resources/updated">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type Result = StripWireOnly>; - -// @public (undocumented) -const ResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$loose>; - -// @public (undocumented) -export type ResultTypeMap = { - ping: EmptyResult; - initialize: InitializeResult; - 'completion/complete': CompleteResult; - 'logging/setLevel': EmptyResult; - 'prompts/get': GetPromptResult; - 'prompts/list': ListPromptsResult; - 'resources/list': ListResourcesResult; - 'resources/templates/list': ListResourceTemplatesResult; - 'resources/read': ReadResourceResult; - 'resources/subscribe': EmptyResult; - 'resources/unsubscribe': EmptyResult; - 'tools/call': CallToolResult; - 'tools/list': ListToolsResult; - 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools; - 'elicitation/create': ElicitResult; - 'roots/list': ListRootsResult; -}; - -// @public (undocumented) -export type Role = Infer; - -// @public -const RoleSchema: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; -}>; - -// @public (undocumented) -export type Root = Infer; - -// @public -const RootSchema: z.ZodObject<{ - uri: z.ZodString; - name: z.ZodOptional; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type RootsListChangedNotification = Infer; - -// @public -const RootsListChangedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/roots/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public -const SPEC_SCHEMA_KEYS: readonly ["AnnotationsSchema", "AudioContentSchema", "BaseMetadataSchema", "BlobResourceContentsSchema", "BooleanSchemaSchema", "CallToolRequestSchema", "CallToolRequestParamsSchema", "CallToolResultSchema", "CancelledNotificationSchema", "CancelledNotificationParamsSchema", "CancelTaskRequestSchema", "CancelTaskResultSchema", "ClientCapabilitiesSchema", "ClientNotificationSchema", "ClientRequestSchema", "ClientResultSchema", "CompatibilityCallToolResultSchema", "CompleteRequestSchema", "CompleteRequestParamsSchema", "CompleteResultSchema", "ContentBlockSchema", "CreateMessageRequestSchema", "CreateMessageRequestParamsSchema", "CreateMessageResultSchema", "CreateMessageResultWithToolsSchema", "CreateTaskResultSchema", "CursorSchema", "DiscoverRequestSchema", "DiscoverResultSchema", "ElicitationCompleteNotificationSchema", "ElicitationCompleteNotificationParamsSchema", "ElicitRequestSchema", "ElicitRequestFormParamsSchema", "ElicitRequestParamsSchema", "ElicitRequestURLParamsSchema", "ElicitResultSchema", "EmbeddedResourceSchema", "EmptyResultSchema", "EnumSchemaSchema", "GetPromptRequestSchema", "GetPromptRequestParamsSchema", "GetPromptResultSchema", "GetTaskPayloadRequestSchema", "GetTaskPayloadResultSchema", "GetTaskRequestSchema", "GetTaskResultSchema", "IconSchema", "IconsSchema", "ImageContentSchema", "ImplementationSchema", "InitializedNotificationSchema", "InitializeRequestSchema", "InitializeRequestParamsSchema", "InitializeResultSchema", "JSONArraySchema", "JSONObjectSchema", "JSONRPCErrorResponseSchema", "JSONRPCMessageSchema", "JSONRPCNotificationSchema", "JSONRPCRequestSchema", "JSONRPCResponseSchema", "JSONRPCResultResponseSchema", "JSONValueSchema", "LegacyTitledEnumSchemaSchema", "ListPromptsRequestSchema", "ListPromptsResultSchema", "ListResourcesRequestSchema", "ListResourcesResultSchema", "ListResourceTemplatesRequestSchema", "ListResourceTemplatesResultSchema", "ListRootsRequestSchema", "ListRootsResultSchema", "ListTasksRequestSchema", "ListTasksResultSchema", "ListToolsRequestSchema", "ListToolsResultSchema", "LoggingLevelSchema", "LoggingMessageNotificationSchema", "LoggingMessageNotificationParamsSchema", "ModelHintSchema", "ModelPreferencesSchema", "MultiSelectEnumSchemaSchema", "NotificationSchema", "NumberSchemaSchema", "PaginatedRequestSchema", "PaginatedRequestParamsSchema", "PaginatedResultSchema", "PingRequestSchema", "PrimitiveSchemaDefinitionSchema", "ProgressSchema", "ProgressNotificationSchema", "ProgressNotificationParamsSchema", "ProgressTokenSchema", "PromptSchema", "PromptArgumentSchema", "PromptListChangedNotificationSchema", "PromptMessageSchema", "PromptReferenceSchema", "ReadResourceRequestSchema", "ReadResourceRequestParamsSchema", "ReadResourceResultSchema", "RelatedTaskMetadataSchema", "RequestSchema", "RequestIdSchema", "RequestMetaEnvelopeSchema", "RequestMetaSchema", "ResourceSchema", "ResourceContentsSchema", "ResourceLinkSchema", "ResourceListChangedNotificationSchema", "ResourceRequestParamsSchema", "ResourceTemplateSchema", "ResourceTemplateReferenceSchema", "ResourceUpdatedNotificationSchema", "ResourceUpdatedNotificationParamsSchema", "ResultSchema", "RoleSchema", "RootSchema", "RootsListChangedNotificationSchema", "SamplingContentSchema", "SamplingMessageSchema", "SamplingMessageContentBlockSchema", "ServerCapabilitiesSchema", "ServerNotificationSchema", "ServerRequestSchema", "ServerResultSchema", "SetLevelRequestSchema", "SetLevelRequestParamsSchema", "SingleSelectEnumSchemaSchema", "StringSchemaSchema", "SubscribeRequestSchema", "SubscribeRequestParamsSchema", "TaskSchema", "TaskAugmentedRequestParamsSchema", "TaskCreationParamsSchema", "TaskMetadataSchema", "TaskStatusSchema", "TaskStatusNotificationSchema", "TaskStatusNotificationParamsSchema", "TextContentSchema", "TextResourceContentsSchema", "TitledMultiSelectEnumSchemaSchema", "TitledSingleSelectEnumSchemaSchema", "ToolSchema", "ToolAnnotationsSchema", "ToolChoiceSchema", "ToolExecutionSchema", "ToolListChangedNotificationSchema", "ToolResultContentSchema", "ToolUseContentSchema", "UnsubscribeRequestSchema", "UnsubscribeRequestParamsSchema", "UntitledMultiSelectEnumSchemaSchema", "UntitledSingleSelectEnumSchemaSchema"]; - -// @public (undocumented) -export const STDIO_DEFAULT_MAX_BUFFER_SIZE: number; - -// @public (undocumented) -export const SUPPORTED_PROTOCOL_VERSIONS: string[]; - -// @public (undocumented) -export type SamplingContent = Infer; - -// @public -const SamplingContentSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ - type: z.ZodLiteral<"text">; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>], "type">; - -// @public (undocumented) -export type SamplingMessage = Infer; - -// @public (undocumented) -export type SamplingMessageContentBlock = Infer; - -// @public -const SamplingMessageContentBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ - type: z.ZodLiteral<"text">; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; -}, z.core.$strip>], "type">; - -// @public -const SamplingMessageSchema: z.ZodObject<{ - role: z.ZodEnum<{ - user: "user"; - assistant: "assistant"; - }>; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -type SchemaFor = K extends ProtocolSchemaKey ? (typeof schemas_d_exports)[K] : K extends AuthSchemaKey ? (typeof authSchemas)[K] : never; - -// @public (undocumented) -type SchemaKey = ProtocolSchemaKey | AuthSchemaKey; - -// @public (undocumented) -type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync }; - -// @public -export class SdkError extends Error { - constructor(code: SdkErrorCode, message: string, data?: unknown | undefined); - // (undocumented) - readonly code: SdkErrorCode; - // (undocumented) - readonly data?: unknown | undefined; -} - -// @public -export enum SdkErrorCode { - AlreadyConnected = "ALREADY_CONNECTED", - CapabilityNotSupported = "CAPABILITY_NOT_SUPPORTED", - // (undocumented) - ClientHttpAuthentication = "CLIENT_HTTP_AUTHENTICATION", - // (undocumented) - ClientHttpFailedToOpenStream = "CLIENT_HTTP_FAILED_TO_OPEN_STREAM", - // (undocumented) - ClientHttpFailedToTerminateSession = "CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION", - // (undocumented) - ClientHttpForbidden = "CLIENT_HTTP_FORBIDDEN", - // (undocumented) - ClientHttpNotImplemented = "CLIENT_HTTP_NOT_IMPLEMENTED", - // (undocumented) - ClientHttpUnexpectedContent = "CLIENT_HTTP_UNEXPECTED_CONTENT", - ConnectionClosed = "CONNECTION_CLOSED", - InvalidResult = "INVALID_RESULT", - NotConnected = "NOT_CONNECTED", - NotInitialized = "NOT_INITIALIZED", - RequestTimeout = "REQUEST_TIMEOUT", - SendFailed = "SEND_FAILED", - UnsupportedResultType = "UNSUPPORTED_RESULT_TYPE", -} - -// @public -export class SdkHttpError extends SdkError { - constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData); - // (undocumented) - readonly data: SdkHttpErrorData; - // (undocumented) - get status(): number; - // (undocumented) - get statusText(): string | undefined; -} - -// @public -export interface SdkHttpErrorData { - // (undocumented) - [key: string]: unknown; - // (undocumented) - status: number; - // (undocumented) - statusText?: string; -} - -// @public @deprecated -export class Server extends Protocol { - constructor(_serverInfo: Implementation, options?: ServerOptions); - // (undocumented) - protected assertCapabilityForMethod(method: RequestMethod | string): void; - // (undocumented) - protected assertNotificationCapability(method: NotificationMethod | string): void; - // (undocumented) - protected assertRequestHandlerCapability(method: string): void; - // (undocumented) - protected buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext; - createElicitationCompletionNotifier(elicitationId: string, options?: NotificationOptions_2): () => Promise; - createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; - createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; - createMessage(params: CreateMessageRequest['params'], options?: RequestOptions): Promise; - elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise; - getCapabilities(): ServerCapabilities; - getClientCapabilities(): ClientCapabilities | undefined; - getClientVersion(): Implementation | undefined; - getNegotiatedProtocolVersion(): string | undefined; - // (undocumented) - listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise; - oninitialized?: () => void; - // (undocumented) - ping(): Promise; - registerCapabilities(capabilities: ServerCapabilities): void; - sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string): Promise; - // (undocumented) - sendPromptListChanged(): Promise; - // (undocumented) - sendResourceListChanged(): Promise; - // (undocumented) - sendResourceUpdated(params: ResourceUpdatedNotification['params']): Promise; - // (undocumented) - sendToolListChanged(): Promise; - protected _wrapHandler(method: string, handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise): (request: JSONRPCRequest, ctx: ServerContext) => Promise; -} - -// @public (undocumented) -export type ServerCapabilities = Infer; - -// @public -const ServerCapabilitiesSchema: z.ZodObject<{ - experimental: z.ZodOptional>>>; - logging: z.ZodOptional>>; - completions: z.ZodOptional>>; - prompts: z.ZodOptional; - }, z.core.$strip>>; - resources: z.ZodOptional; - listChanged: z.ZodOptional; - }, z.core.$strip>>; - tools: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; -}, z.core.$strip>; - -// @public -export type ServerContext = BaseContext & { - mcpReq: { - log: (level: LoggingLevel, data: unknown, logger?: string) => Promise; - elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; - requestSampling: (params: CreateMessageRequest['params'], options?: RequestOptions) => Promise; - }; - http?: { - req?: globalThis.Request; - closeSSE?: () => void; - closeStandaloneSSE?: () => void; - }; -}; - -// @public (undocumented) -export type ServerNotification = Infer; - -// @public (undocumented) -const ServerNotificationSchema: z.ZodUnion; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - requestId: z.ZodOptional>; - reason: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/progress">; - params: z.ZodObject<{ - progressToken: z.ZodUnion; - progress: z.ZodNumber; - total: z.ZodOptional; - message: z.ZodOptional; - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/message">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; - logger: z.ZodOptional; - data: z.ZodUnknown; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/resources/updated">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/resources/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/tools/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/prompts/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/tasks/status">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"notifications/elicitation/complete">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - elicitationId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type ServerOptions = ProtocolOptions & { - capabilities?: ServerCapabilities; - instructions?: string; - jsonSchemaValidator?: jsonSchemaValidator; -}; - -// @public (undocumented) -export type ServerRequest = Infer; - -// @public (undocumented) -const ServerRequestSchema: z.ZodUnion; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"sampling/createMessage">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - messages: z.ZodArray; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">, z.ZodArray; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; - }, z.core.$strip>], "type">>]>; - _meta: z.ZodOptional>; - }, z.core.$strip>>; - modelPreferences: z.ZodOptional; - }, z.core.$strip>>>; - costPriority: z.ZodOptional; - speedPriority: z.ZodOptional; - intelligencePriority: z.ZodOptional; - }, z.core.$strip>>; - systemPrompt: z.ZodOptional; - includeContext: z.ZodOptional>; - temperature: z.ZodOptional; - maxTokens: z.ZodNumber; - stopSequences: z.ZodOptional>; - metadata: z.ZodOptional>>; - tools: z.ZodOptional; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>>; - toolChoice: z.ZodOptional>; - }, z.core.$strip>>; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"elicitation/create">; - params: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodOptional>; - message: z.ZodString; - requestedSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodRecord; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - enumNames: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; - }, z.core.$strip>]>, z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; - }, z.core.$strip>]>]>, z.ZodObject<{ - type: z.ZodLiteral<"boolean">; - title: z.ZodOptional; - description: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodEnum<{ - number: "number"; - integer: "integer"; - }>; - title: z.ZodOptional; - description: z.ZodOptional; - minimum: z.ZodOptional; - maximum: z.ZodOptional; - default: z.ZodOptional; - }, z.core.$strip>]>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - }, z.core.$strip>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; - mode: z.ZodLiteral<"url">; - message: z.ZodString; - elicitationId: z.ZodString; - url: z.ZodString; - }, z.core.$strip>]>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"roots/list">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/get">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/result">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>, z.ZodObject<{ - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - cursor: z.ZodOptional; - }, z.core.$strip>>; - method: z.ZodLiteral<"tasks/list">; -}, z.core.$strip>, z.ZodObject<{ - method: z.ZodLiteral<"tasks/cancel">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>]>; - -// @public (undocumented) -export type ServerResult = StripWireOnly>; - -// @public (undocumented) -const ServerResultSchema: z.ZodUnion>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$strict>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - protocolVersion: z.ZodString; - capabilities: z.ZodObject<{ - experimental: z.ZodOptional>>>; - logging: z.ZodOptional>>; - completions: z.ZodOptional>>; - prompts: z.ZodOptional; - }, z.core.$strip>>; - resources: z.ZodOptional; - listChanged: z.ZodOptional; - }, z.core.$strip>>; - tools: z.ZodOptional; - }, z.core.$strip>>; - tasks: z.ZodOptional>>; - cancel: z.ZodOptional>>; - requests: z.ZodOptional>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - }, z.core.$loose>>; - extensions: z.ZodOptional>>>; - }, z.core.$strip>; - serverInfo: z.ZodObject<{ - version: z.ZodString; - websiteUrl: z.ZodOptional; - description: z.ZodOptional; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>; - instructions: z.ZodOptional; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - completion: z.ZodObject<{ - values: z.ZodArray; - total: z.ZodOptional; - hasMore: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - description: z.ZodOptional; - messages: z.ZodArray; - content: z.ZodUnion; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - prompts: z.ZodArray; - arguments: z.ZodOptional; - required: z.ZodOptional; - }, z.core.$strip>>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - resources: z.ZodArray; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - resourceTemplates: z.ZodArray; - mimeType: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - contents: z.ZodArray; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - tools: z.ZodArray; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - nextCursor: z.ZodOptional; - tasks: z.ZodArray; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$loose>, z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - task: z.ZodObject<{ - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$loose>]>; - -// @public (undocumented) -export type SetLevelRequest = Infer; - -// @public (undocumented) -export type SetLevelRequestParams = Infer; - -// @public -const SetLevelRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; -}, z.core.$strip>; - -// @public -const SetLevelRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"logging/setLevel">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - level: z.ZodEnum<{ - error: "error"; - debug: "debug"; - info: "info"; - notice: "notice"; - warning: "warning"; - critical: "critical"; - alert: "alert"; - emergency: "emergency"; - }>; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public (undocumented) -export type SingleSelectEnumSchema = Infer; - -// @public (undocumented) -const SingleSelectEnumSchemaSchema: z.ZodUnion; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; -}, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; -}, z.core.$strip>]>; - -// @public -type SpecTypeInputs = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.input> : never }; - -// @public -export type SpecTypeName = StripSchemaSuffix; - -// @public -export type SpecTypes = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.output> : never }; - -// @public (undocumented) -interface StandardJSONSchemaV1 { - // (undocumented) - readonly '~standard': StandardJSONSchemaV1.Props; -} - -// @public (undocumented) -namespace StandardJSONSchemaV1 { - // (undocumented) - interface Converter { - // (undocumented) - readonly input: (options: Options) => Record; - // (undocumented) - readonly output: (options: Options) => Record; - } - // (undocumented) - type InferInput = StandardTypedV1.InferInput; - // (undocumented) - type InferOutput = StandardTypedV1.InferOutput; - // (undocumented) - interface Options { - // (undocumented) - readonly libraryOptions?: Record | undefined; - // (undocumented) - readonly target: Target; - } - // (undocumented) - interface Props extends StandardTypedV1.Props { - // (undocumented) - readonly jsonSchema: Converter; - } - // (undocumented) - type Target = 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | (object & string); -} - -// @public (undocumented) -export interface StandardSchemaV1 { - // (undocumented) - readonly '~standard': StandardSchemaV1.Props; -} - -// @public (undocumented) -export namespace StandardSchemaV1 { - // (undocumented) - export interface FailureResult { - // (undocumented) - readonly issues: ReadonlyArray; - } - // (undocumented) - export type InferInput = StandardTypedV1.InferInput; - // (undocumented) - export type InferOutput = StandardTypedV1.InferOutput; - // (undocumented) - export interface Issue { - // (undocumented) - readonly message: string; - // (undocumented) - readonly path?: ReadonlyArray | undefined; - } - // (undocumented) - export interface Options { - // (undocumented) - readonly libraryOptions?: Record | undefined; - } - // (undocumented) - export interface PathSegment { - // (undocumented) - readonly key: PropertyKey; - } - // (undocumented) - export interface Props extends StandardTypedV1.Props { - // (undocumented) - readonly validate: (value: unknown, options?: Options | undefined) => Result | Promise>; - } - // (undocumented) - export type Result = SuccessResult | FailureResult; - // (undocumented) - export interface SuccessResult { - // (undocumented) - readonly issues?: undefined; - // (undocumented) - readonly value: Output; - } -} - -// @public -export interface StandardSchemaV1Sync extends StandardSchemaV1 { - // (undocumented) - readonly '~standard': StandardSchemaV1Sync.Props; -} - -// @public (undocumented) -export namespace StandardSchemaV1Sync { - // (undocumented) - export type InferInput = StandardTypedV1.InferInput; - // (undocumented) - export type InferOutput = StandardTypedV1.InferOutput; - // (undocumented) - export interface Props extends StandardSchemaV1.Props { - // (undocumented) - readonly validate: (value: unknown, options?: StandardSchemaV1.Options | undefined) => StandardSchemaV1.Result; - } -} - -// @public -export interface StandardSchemaWithJSON { - // (undocumented) - readonly '~standard': StandardSchemaV1.Props & StandardJSONSchemaV1.Props; -} - -// @public (undocumented) -export namespace StandardSchemaWithJSON { - // (undocumented) - export type InferInput = StandardTypedV1.InferInput; - // (undocumented) - export type InferOutput = StandardTypedV1.InferOutput; -} - -// @public -interface StandardTypedV1 { - // (undocumented) - readonly '~standard': StandardTypedV1.Props; -} - -// @public (undocumented) -namespace StandardTypedV1 { - // (undocumented) - type InferInput = NonNullable['input']; - // (undocumented) - type InferOutput = NonNullable['output']; - // (undocumented) - interface Props { - // (undocumented) - readonly types?: Types | undefined; - // (undocumented) - readonly vendor: string; - // (undocumented) - readonly version: 1; - } - // (undocumented) - interface Types { - // (undocumented) - readonly input: Input; - // (undocumented) - readonly output: Output; - } -} - -// @public (undocumented) -export type StreamId = string; - -// @public (undocumented) -export type StringSchema = Infer; - -// @public -const StringSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - minLength: z.ZodOptional; - maxLength: z.ZodOptional; - format: z.ZodOptional>; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -type StripSchemaSuffix = K extends `${infer N}Schema` ? N : never; - -// @public -type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; - -// @public (undocumented) -export type SubscribeRequest = Infer; - -// @public (undocumented) -export type SubscribeRequestParams = Infer; - -// @public (undocumented) -const SubscribeRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const SubscribeRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"resources/subscribe">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type Task = Infer; - -// @public @deprecated (undocumented) -export type TaskAugmentedRequestParams = Infer; - -// @public @deprecated -const TaskAugmentedRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - task: z.ZodOptional; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type TaskCreationParams = Infer; - -// @public @deprecated -const TaskCreationParamsSchema: z.ZodObject<{ - ttl: z.ZodOptional; - pollInterval: z.ZodOptional; -}, z.core.$loose>; - -// @public @deprecated (undocumented) -export type TaskMetadata = Infer; - -// @public @deprecated (undocumented) -const TaskMetadataSchema: z.ZodObject<{ - ttl: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -type TaskNotificationMethod = 'notifications/tasks/status'; - -// @public -type TaskRequestMethod = 'tasks/get' | 'tasks/result' | 'tasks/list' | 'tasks/cancel'; - -// @public @deprecated -const TaskSchema: z.ZodObject<{ - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>; - -// @public @deprecated (undocumented) -export type TaskStatus = Infer; - -// @public @deprecated (undocumented) -export type TaskStatusNotification = Infer; - -// @public @deprecated (undocumented) -export type TaskStatusNotificationParams = Infer; - -// @public @deprecated -const TaskStatusNotificationParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; -}, z.core.$strip>; - -// @public @deprecated -const TaskStatusNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/tasks/status">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - taskId: z.ZodString; - status: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; - }>; - ttl: z.ZodUnion; - createdAt: z.ZodString; - lastUpdatedAt: z.ZodString; - pollInterval: z.ZodOptional; - statusMessage: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public @deprecated -const TaskStatusSchema: z.ZodEnum<{ - working: "working"; - input_required: "input_required"; - completed: "completed"; - failed: "failed"; - cancelled: "cancelled"; -}>; - -// @public (undocumented) -export type TextContent = Infer; - -// @public -const TextContentSchema: z.ZodObject<{ - type: z.ZodLiteral<"text">; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type TextResourceContents = Infer; - -// @public (undocumented) -const TextResourceContentsSchema: z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - text: z.ZodString; -}, z.core.$strip>; - -// @public (undocumented) -export type TitledMultiSelectEnumSchema = Infer; - -// @public -const TitledMultiSelectEnumSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - anyOf: z.ZodArray>; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type TitledSingleSelectEnumSchema = Infer; - -// @public -const TitledSingleSelectEnumSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - oneOf: z.ZodArray>; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type Tool = Infer; - -// @public (undocumented) -export type ToolAnnotations = Infer; - -// @public -const ToolAnnotationsSchema: z.ZodObject<{ - title: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; -}, z.core.$strip>; - -// @public -export type ToolCallback = BaseToolCallback; - -// @public (undocumented) -export type ToolChoice = Infer; - -// @public -const ToolChoiceSchema: z.ZodObject<{ - mode: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type ToolExecution = Infer; - -// @public -const ToolExecutionSchema: z.ZodObject<{ - taskSupport: z.ZodOptional>; -}, z.core.$strip>; - -// @public -type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; - -// @public (undocumented) -export type ToolListChangedNotification = Infer; - -// @public -const ToolListChangedNotificationSchema: z.ZodObject<{ - method: z.ZodLiteral<"notifications/tools/list_changed">; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$strip>>; -}, z.core.$strip>; - -// @public (undocumented) -export type ToolResultContent = Infer; - -// @public -const ToolResultContentSchema: z.ZodObject<{ - type: z.ZodLiteral<"tool_result">; - toolUseId: z.ZodString; - content: z.ZodDefault; - text: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"image">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"audio">; - data: z.ZodString; - mimeType: z.ZodString; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - description: z.ZodOptional; - mimeType: z.ZodOptional; - size: z.ZodOptional; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; - type: z.ZodLiteral<"resource_link">; - }, z.core.$strip>, z.ZodObject<{ - type: z.ZodLiteral<"resource">; - resource: z.ZodUnion; - _meta: z.ZodOptional>; - text: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - uri: z.ZodString; - mimeType: z.ZodOptional; - _meta: z.ZodOptional>; - blob: z.ZodString; - }, z.core.$strip>]>; - annotations: z.ZodOptional>>; - priority: z.ZodOptional; - lastModified: z.ZodOptional; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - }, z.core.$strip>]>>>; - structuredContent: z.ZodOptional>; - isError: z.ZodOptional; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public -const ToolSchema: z.ZodObject<{ - description: z.ZodOptional; - inputSchema: z.ZodObject<{ - type: z.ZodLiteral<"object">; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>; - outputSchema: z.ZodOptional; - properties: z.ZodOptional>>>; - required: z.ZodOptional>; - }, z.core.$catchall>>; - annotations: z.ZodOptional; - readOnlyHint: z.ZodOptional; - destructiveHint: z.ZodOptional; - idempotentHint: z.ZodOptional; - openWorldHint: z.ZodOptional; - }, z.core.$strip>>; - execution: z.ZodOptional>; - }, z.core.$strip>>; - _meta: z.ZodOptional>; - icons: z.ZodOptional; - sizes: z.ZodOptional>; - theme: z.ZodOptional>; - }, z.core.$strip>>>; - name: z.ZodString; - title: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export type ToolUseContent = Infer; - -// @public -const ToolUseContentSchema: z.ZodObject<{ - type: z.ZodLiteral<"tool_use">; - name: z.ZodString; - id: z.ZodString; - input: z.ZodRecord; - _meta: z.ZodOptional>; -}, z.core.$strip>; - -// @public -export interface Transport { - close(): Promise; - onclose?: (() => void) | undefined; - onerror?: ((error: Error) => void) | undefined; - onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; - send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; - sessionId?: string | undefined; - setProtocolVersion?: ((version: string) => void) | undefined; - setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; - start(): Promise; -} - -// @public -export type TransportSendOptions = { - relatedRequestId?: RequestId | undefined; - resumptionToken?: string | undefined; - onresumptiontoken?: ((token: string) => void) | undefined; -}; - -// @public (undocumented) -export type UnsubscribeRequest = Infer; - -// @public (undocumented) -export type UnsubscribeRequestParams = Infer; - -// @public (undocumented) -const UnsubscribeRequestParamsSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; -}, z.core.$strip>; - -// @public -const UnsubscribeRequestSchema: z.ZodObject<{ - method: z.ZodLiteral<"resources/unsubscribe">; - params: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - uri: z.ZodString; - }, z.core.$strip>; -}, z.core.$strip>; - -// @public -export class UnsupportedProtocolVersionError extends ProtocolError { - constructor(data: UnsupportedProtocolVersionErrorData, message?: string); - get requested(): string; - get supported(): string[]; -} - -// @public -export interface UnsupportedProtocolVersionErrorData { - requested: string; - supported: string[]; -} - -// @public (undocumented) -export type UntitledMultiSelectEnumSchema = Infer; - -// @public -const UntitledMultiSelectEnumSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"array">; - title: z.ZodOptional; - description: z.ZodOptional; - minItems: z.ZodOptional; - maxItems: z.ZodOptional; - items: z.ZodObject<{ - type: z.ZodLiteral<"string">; - enum: z.ZodArray; - }, z.core.$strip>; - default: z.ZodOptional>; -}, z.core.$strip>; - -// @public (undocumented) -export type UntitledSingleSelectEnumSchema = Infer; - -// @public -const UntitledSingleSelectEnumSchemaSchema: z.ZodObject<{ - type: z.ZodLiteral<"string">; - title: z.ZodOptional; - description: z.ZodOptional; - enum: z.ZodArray; - default: z.ZodOptional; -}, z.core.$strip>; - -// @public (undocumented) -export class UriTemplate { - constructor(template: string); - // (undocumented) - expand(variables: Variables): string; - static isTemplate(str: string): boolean; - // (undocumented) - match(uri: string): Variables | null; - // (undocumented) - toString(): string; - // (undocumented) - get variableNames(): string[]; -} - -// @public -export class UrlElicitationRequiredError extends ProtocolError { - constructor(elicitations: ElicitRequestURLParams[], message?: string); - // (undocumented) - get elicitations(): ElicitRequestURLParams[]; -} - -// @public (undocumented) -export type Variables = Record; - -// @public -export class WebStandardStreamableHTTPServerTransport implements Transport { - constructor(options?: WebStandardStreamableHTTPServerTransportOptions); - // (undocumented) - close(): Promise; - closeSSEStream(requestId: RequestId): void; - closeStandaloneSSEStream(): void; - handleRequest(req: Request, options?: HandleRequestOptions): Promise; - // (undocumented) - onclose?: () => void; - // (undocumented) - onerror?: (error: Error) => void; - // (undocumented) - onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; - // (undocumented) - send(message: JSONRPCMessage, options?: { - relatedRequestId?: RequestId; - }): Promise; - // (undocumented) - sessionId?: string; - setSupportedProtocolVersions(versions: string[]): void; - start(): Promise; -} - -// @public -export interface WebStandardStreamableHTTPServerTransportOptions { - // @deprecated - allowedHosts?: string[]; - // @deprecated - allowedOrigins?: string[]; - // @deprecated - enableDnsRebindingProtection?: boolean; - enableJsonResponse?: boolean; - eventStore?: EventStore; - onsessionclosed?: ((sessionId: string) => void | Promise) | undefined; - onsessioninitialized?: ((sessionId: string) => void | Promise) | undefined; - retryInterval?: number; - sessionIdGenerator?: (() => string) | undefined; - supportedProtocolVersions?: string[]; -} - -// @public -type WireOnlyResultKey = 'resultType'; - -// @public -type ZodRawShape = Record; - -// @public (undocumented) -export function assertCompleteRequestPrompt(request: CompleteRequest): asserts request is CompleteRequestPrompt; - -// @public (undocumented) -export function assertCompleteRequestResourceTemplate(request: CompleteRequest): asserts request is CompleteRequestResourceTemplate; - -// @public (undocumented) -const authSchemas: { - readonly OAuthClientInformationFullSchema: z.ZodObject<{ - redirect_uris: z.ZodArray; - token_endpoint_auth_method: z.ZodOptional; - grant_types: z.ZodOptional>; - response_types: z.ZodOptional>; - client_name: z.ZodOptional; - client_uri: z.ZodOptional; - logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - scope: z.ZodOptional; - contacts: z.ZodOptional>; - tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - policy_uri: z.ZodOptional; - jwks_uri: z.ZodOptional; - jwks: z.ZodOptional; - software_id: z.ZodOptional; - software_version: z.ZodOptional; - software_statement: z.ZodOptional; - client_id: z.ZodString; - client_secret: z.ZodOptional; - client_id_issued_at: z.ZodOptional; - client_secret_expires_at: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthClientInformationSchema: z.ZodObject<{ - client_id: z.ZodString; - client_secret: z.ZodOptional; - client_id_issued_at: z.ZodOptional; - client_secret_expires_at: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthClientMetadataSchema: z.ZodObject<{ - redirect_uris: z.ZodArray; - token_endpoint_auth_method: z.ZodOptional; - grant_types: z.ZodOptional>; - response_types: z.ZodOptional>; - client_name: z.ZodOptional; - client_uri: z.ZodOptional; - logo_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - scope: z.ZodOptional; - contacts: z.ZodOptional>; - tos_uri: z.ZodUnion<[z.ZodOptional, z.ZodPipe, z.ZodTransform>]>; - policy_uri: z.ZodOptional; - jwks_uri: z.ZodOptional; - jwks: z.ZodOptional; - software_id: z.ZodOptional; - software_version: z.ZodOptional; - software_statement: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthClientRegistrationErrorSchema: z.ZodObject<{ - error: z.ZodString; - error_description: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthErrorResponseSchema: z.ZodObject<{ - error: z.ZodString; - error_description: z.ZodOptional; - error_uri: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - revocation_endpoint: z.ZodOptional; - revocation_endpoint_auth_methods_supported: z.ZodOptional>; - revocation_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - introspection_endpoint: z.ZodOptional; - introspection_endpoint_auth_methods_supported: z.ZodOptional>; - introspection_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - code_challenge_methods_supported: z.ZodOptional>; - client_id_metadata_document_supported: z.ZodOptional; - }, z.core.$loose>; - readonly OAuthProtectedResourceMetadataSchema: z.ZodObject<{ - resource: z.ZodString; - authorization_servers: z.ZodOptional>; - jwks_uri: z.ZodOptional; - scopes_supported: z.ZodOptional>; - bearer_methods_supported: z.ZodOptional>; - resource_signing_alg_values_supported: z.ZodOptional>; - resource_name: z.ZodOptional; - resource_documentation: z.ZodOptional; - resource_policy_uri: z.ZodOptional; - resource_tos_uri: z.ZodOptional; - tls_client_certificate_bound_access_tokens: z.ZodOptional; - authorization_details_types_supported: z.ZodOptional>; - dpop_signing_alg_values_supported: z.ZodOptional>; - dpop_bound_access_tokens_required: z.ZodOptional; - }, z.core.$loose>; - readonly OAuthTokenRevocationRequestSchema: z.ZodObject<{ - token: z.ZodString; - token_type_hint: z.ZodOptional; - }, z.core.$strip>; - readonly OAuthTokensSchema: z.ZodObject<{ - access_token: z.ZodString; - id_token: z.ZodOptional; - token_type: z.ZodString; - expires_in: z.ZodOptional>; - scope: z.ZodOptional; - refresh_token: z.ZodOptional; - }, z.core.$strip>; - readonly OpenIdProviderDiscoveryMetadataSchema: z.ZodObject<{ - code_challenge_methods_supported: z.ZodOptional>; - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - userinfo_endpoint: z.ZodOptional; - jwks_uri: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - acr_values_supported: z.ZodOptional>; - subject_types_supported: z.ZodArray; - id_token_signing_alg_values_supported: z.ZodArray; - id_token_encryption_alg_values_supported: z.ZodOptional>; - id_token_encryption_enc_values_supported: z.ZodOptional>; - userinfo_signing_alg_values_supported: z.ZodOptional>; - userinfo_encryption_alg_values_supported: z.ZodOptional>; - userinfo_encryption_enc_values_supported: z.ZodOptional>; - request_object_signing_alg_values_supported: z.ZodOptional>; - request_object_encryption_alg_values_supported: z.ZodOptional>; - request_object_encryption_enc_values_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - display_values_supported: z.ZodOptional>; - claim_types_supported: z.ZodOptional>; - claims_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - claims_locales_supported: z.ZodOptional>; - ui_locales_supported: z.ZodOptional>; - claims_parameter_supported: z.ZodOptional; - request_parameter_supported: z.ZodOptional; - request_uri_parameter_supported: z.ZodOptional; - require_request_uri_registration: z.ZodOptional; - op_policy_uri: z.ZodOptional; - op_tos_uri: z.ZodOptional; - client_id_metadata_document_supported: z.ZodOptional; - }, z.core.$strip>; - readonly OpenIdProviderMetadataSchema: z.ZodObject<{ - issuer: z.ZodString; - authorization_endpoint: z.ZodURL; - token_endpoint: z.ZodURL; - userinfo_endpoint: z.ZodOptional; - jwks_uri: z.ZodURL; - registration_endpoint: z.ZodOptional; - scopes_supported: z.ZodOptional>; - response_types_supported: z.ZodArray; - response_modes_supported: z.ZodOptional>; - grant_types_supported: z.ZodOptional>; - acr_values_supported: z.ZodOptional>; - subject_types_supported: z.ZodArray; - id_token_signing_alg_values_supported: z.ZodArray; - id_token_encryption_alg_values_supported: z.ZodOptional>; - id_token_encryption_enc_values_supported: z.ZodOptional>; - userinfo_signing_alg_values_supported: z.ZodOptional>; - userinfo_encryption_alg_values_supported: z.ZodOptional>; - userinfo_encryption_enc_values_supported: z.ZodOptional>; - request_object_signing_alg_values_supported: z.ZodOptional>; - request_object_encryption_alg_values_supported: z.ZodOptional>; - request_object_encryption_enc_values_supported: z.ZodOptional>; - token_endpoint_auth_methods_supported: z.ZodOptional>; - token_endpoint_auth_signing_alg_values_supported: z.ZodOptional>; - display_values_supported: z.ZodOptional>; - claim_types_supported: z.ZodOptional>; - claims_supported: z.ZodOptional>; - service_documentation: z.ZodOptional; - claims_locales_supported: z.ZodOptional>; - ui_locales_supported: z.ZodOptional>; - claims_parameter_supported: z.ZodOptional; - request_parameter_supported: z.ZodOptional; - request_uri_parameter_supported: z.ZodOptional; - require_request_uri_registration: z.ZodOptional; - op_policy_uri: z.ZodOptional; - op_tos_uri: z.ZodOptional; - client_id_metadata_document_supported: z.ZodOptional; - }, z.core.$loose>; -}; - -// @public -export function checkResourceAllowed(input: { - requestedResource: URL | string; - configuredResource: URL | string; -}): boolean; - -// @public -export function completable(schema: T, complete: CompleteCallback): CompletableSchema; - -// @public -export function createFetchWithInit(baseFetch?: FetchLike, baseInit?: RequestInit): FetchLike; - -// @public (undocumented) -export function deserializeMessage(line: string): JSONRPCMessage; - -// @public (undocumented) -export function fromJsonSchema(schema: JsonSchemaType, validator?: jsonSchemaValidator): StandardSchemaWithJSON; - -// @public -export function getDisplayName(metadata: BaseMetadata | (BaseMetadata & { - annotations?: { - title?: string; - }; -})): string; - -// @public -export function hostHeaderValidationResponse(req: Request, allowedHostnames: string[]): Response | undefined; - -// @public -export const isCallToolResult: (value: unknown) => value is CallToolResult; - -// @public -export function isCompletable(schema: unknown): schema is CompletableSchema; - -// @public (undocumented) -export const isInitializeRequest: (value: unknown) => value is InitializeRequest; - -// @public (undocumented) -export const isInitializedNotification: (value: unknown) => value is InitializedNotification; - -// @public -export const isJSONRPCErrorResponse: (value: unknown) => value is JSONRPCErrorResponse; - -// @public (undocumented) -export const isJSONRPCNotification: (value: unknown) => value is JSONRPCNotification; - -// @public (undocumented) -export const isJSONRPCRequest: (value: unknown) => value is JSONRPCRequest; - -// @public -export const isJSONRPCResponse: (value: unknown) => value is JSONRPCResponse; - -// @public -export const isJSONRPCResultResponse: (value: unknown) => value is JSONRPCResultResponse; - -// @public -export const isSpecType: GuardRecord; - -// @public @deprecated -export const isTaskAugmentedRequestParams: (value: unknown) => value is TaskAugmentedRequestParams; - -// @public -export interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -export function localhostAllowedHostnames(): string[]; - -// @public -export function parseJSONRPCMessage(value: unknown): JSONRPCMessage; - -// @public -export function resourceUrlFromServerUrl(url: URL | string): URL; - -// @public (undocumented) -namespace schemas_d_exports { - export { AnnotationsSchema, AudioContentSchema, BaseMetadataSchema, BaseRequestParamsSchema, BlobResourceContentsSchema, BooleanSchemaSchema, CallToolRequestParamsSchema, CallToolRequestSchema, CallToolResultSchema, CancelTaskRequestSchema, CancelTaskResultSchema, CancelledNotificationParamsSchema, CancelledNotificationSchema, ClientCapabilitiesSchema, ClientNotificationSchema, ClientRequestSchema, ClientResultSchema, ClientTasksCapabilitySchema, CompatibilityCallToolResultSchema, CompleteRequestParamsSchema, CompleteRequestSchema, CompleteResultSchema, ContentBlockSchema, CreateMessageRequestParamsSchema, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, CreateTaskResultSchema, CursorSchema, DiscoverRequestSchema, DiscoverResultSchema, ElicitRequestFormParamsSchema, ElicitRequestParamsSchema, ElicitRequestSchema, ElicitRequestURLParamsSchema, ElicitResultSchema, ElicitationCompleteNotificationParamsSchema, ElicitationCompleteNotificationSchema, EmbeddedResourceSchema, EmptyResultSchema, EnumSchemaSchema, GetPromptRequestParamsSchema, GetPromptRequestSchema, GetPromptResultSchema, GetTaskPayloadRequestSchema, GetTaskPayloadResultSchema, GetTaskRequestSchema, GetTaskResultSchema, IconSchema, IconsSchema, ImageContentSchema, ImplementationSchema, InitializeRequestParamsSchema, InitializeRequestSchema, InitializeResultSchema, InitializedNotificationSchema, JSONArraySchema, JSONObjectSchema, JSONRPCErrorResponseSchema, JSONRPCMessageSchema, JSONRPCNotificationSchema, JSONRPCRequestSchema, JSONRPCResponseSchema, JSONRPCResultResponseSchema, JSONValueSchema, LegacyTitledEnumSchemaSchema, ListChangedOptionsBaseSchema, ListPromptsRequestSchema, ListPromptsResultSchema, ListResourceTemplatesRequestSchema, ListResourceTemplatesResultSchema, ListResourcesRequestSchema, ListResourcesResultSchema, ListRootsRequestSchema, ListRootsResultSchema, ListTasksRequestSchema, ListTasksResultSchema, ListToolsRequestSchema, ListToolsResultSchema, LoggingLevelSchema, LoggingMessageNotificationParamsSchema, LoggingMessageNotificationSchema, ModelHintSchema, ModelPreferencesSchema, MultiSelectEnumSchemaSchema, NotificationSchema, NotificationsParamsSchema, NumberSchemaSchema, PaginatedRequestParamsSchema, PaginatedRequestSchema, PaginatedResultSchema, PingRequestSchema, PrimitiveSchemaDefinitionSchema, ProgressNotificationParamsSchema, ProgressNotificationSchema, ProgressSchema, ProgressTokenSchema, PromptArgumentSchema, PromptListChangedNotificationSchema, PromptMessageSchema, PromptReferenceSchema, PromptSchema, ReadResourceRequestParamsSchema, ReadResourceRequestSchema, ReadResourceResultSchema, RelatedTaskMetadataSchema, RequestIdSchema, RequestMetaEnvelopeSchema, RequestMetaSchema, RequestSchema, ResourceContentsSchema, ResourceLinkSchema, ResourceListChangedNotificationSchema, ResourceRequestParamsSchema, ResourceSchema, ResourceTemplateReferenceSchema, ResourceTemplateSchema, ResourceUpdatedNotificationParamsSchema, ResourceUpdatedNotificationSchema, ResultSchema, RoleSchema, RootSchema, RootsListChangedNotificationSchema, SamplingContentSchema, SamplingMessageContentBlockSchema, SamplingMessageSchema, ServerCapabilitiesSchema, ServerNotificationSchema, ServerRequestSchema, ServerResultSchema, ServerTasksCapabilitySchema, SetLevelRequestParamsSchema, SetLevelRequestSchema, SingleSelectEnumSchemaSchema, StringSchemaSchema, SubscribeRequestParamsSchema, SubscribeRequestSchema, TaskAugmentedRequestParamsSchema, TaskCreationParamsSchema, TaskMetadataSchema, TaskSchema, TaskStatusNotificationParamsSchema, TaskStatusNotificationSchema, TaskStatusSchema, TextContentSchema, TextResourceContentsSchema, TitledMultiSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema, ToolAnnotationsSchema, ToolChoiceSchema, ToolExecutionSchema, ToolListChangedNotificationSchema, ToolResultContentSchema, ToolSchema, ToolUseContentSchema, UnsubscribeRequestParamsSchema, UnsubscribeRequestSchema, UntitledMultiSelectEnumSchemaSchema, UntitledSingleSelectEnumSchemaSchema, getNotificationSchema, getRequestSchema, getResultSchema }; -} - -// @public (undocumented) -export function serializeMessage(message: JSONRPCMessage): string; - -// @public -export const specTypeSchemas: SchemaRecord; - -// @public -export function validateHostHeader(hostHeader: string | null | undefined, allowedHostnames: string[]): HostHeaderValidationResult; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/server/etc/server.shims-workerd.api.md b/packages/server/etc/server.shims-workerd.api.md deleted file mode 100644 index 916749784a..0000000000 --- a/packages/server/etc/server.shims-workerd.api.md +++ /dev/null @@ -1,51 +0,0 @@ -## API Report File for "@modelcontextprotocol/server" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { JSONSchema } from 'json-schema-typed'; - -// @public -type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; - -// @public -export class DefaultJsonSchemaValidator implements jsonSchemaValidator { - constructor(options?: { - shortcircuit?: boolean; - draft?: CfWorkerSchemaDraft; - }); - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -type JsonSchemaType = JSONSchema.Interface; - -// @public -type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public -interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public (undocumented) -const process_2: { - readonly stdin: never; - readonly stdout: never; -}; -export { process_2 as process } - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/server/etc/server.shims.api.md b/packages/server/etc/server.shims.api.md deleted file mode 100644 index d607994689..0000000000 --- a/packages/server/etc/server.shims.api.md +++ /dev/null @@ -1,60 +0,0 @@ -## API Report File for "@modelcontextprotocol/server" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { JSONSchema } from 'json-schema-typed'; -import { default as process_2 } from 'node:process'; - -export { process_2 as process } - -// @public -interface AjvLike { - // (undocumented) - compile: (schema: unknown) => AjvValidateFunction; - // (undocumented) - errorsText: (errors?: any) => string; - // (undocumented) - getSchema: (keyRef: string) => AjvValidateFunction | undefined; -} - -// @public (undocumented) -interface AjvValidateFunction { - // (undocumented) - (input: unknown): boolean; - // (undocumented) - errors?: any; -} - -// @public -export class DefaultJsonSchemaValidator implements jsonSchemaValidator { - constructor(ajv?: AjvLike); - // (undocumented) - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -type JsonSchemaType = JSONSchema.Interface; - -// @public -type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public -interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/server/etc/server.stdio.api.md b/packages/server/etc/server.stdio.api.md deleted file mode 100644 index fd2b3bd380..0000000000 --- a/packages/server/etc/server.stdio.api.md +++ /dev/null @@ -1,181 +0,0 @@ -## API Report File for "@modelcontextprotocol/server" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { Readable } from 'node:stream'; -import { Writable } from 'node:stream'; -import * as z from 'zod/v4'; - -// @public -interface AuthInfo { - clientId: string; - expiresAt?: number; - extra?: Record; - resource?: URL; - scopes: string[]; - token: string; -} - -// @public (undocumented) -type Flatten = T extends Primitive ? T : T extends Array ? Array> : T extends Set ? Set> : T extends Map ? Map, Flatten> : T extends object ? { [K in keyof T]: Flatten } : T; - -// @public (undocumented) -type Infer = Flatten>; - -// @public (undocumented) -type JSONRPCErrorResponse = Infer; - -// @public -const JSONRPCErrorResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodOptional>; - error: z.ZodObject<{ - code: z.ZodNumber; - message: z.ZodString; - data: z.ZodOptional; - }, z.core.$strip>; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; - -// @public (undocumented) -type JSONRPCNotification = Infer; - -// @public -const JSONRPCNotificationSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCRequest = Infer; - -// @public -const JSONRPCRequestSchema: z.ZodObject<{ - method: z.ZodString; - params: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - }, z.core.$loose>>; - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; -}, z.core.$strict>; - -// @public (undocumented) -type JSONRPCResultResponse = Omit, 'result'> & { - result: Result; -}; - -// @public -const JSONRPCResultResponseSchema: z.ZodObject<{ - jsonrpc: z.ZodLiteral<"2.0">; - id: z.ZodUnion; - result: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; - }, z.core.$loose>; -}, z.core.$strict>; - -// @public -interface MessageExtraInfo { - authInfo?: AuthInfo; - closeSSEStream?: () => void; - closeStandaloneSSEStream?: () => void; - request?: globalThis.Request; -} - -// @public (undocumented) -type Primitive = string | number | boolean | bigint | null | undefined; - -// @public (undocumented) -type RequestId = Infer; - -// @public -const RequestIdSchema: z.ZodUnion; - -// @public (undocumented) -type Result = StripWireOnly>; - -// @public (undocumented) -const ResultSchema: z.ZodObject<{ - _meta: z.ZodOptional>; - "io.modelcontextprotocol/related-task": z.ZodOptional>; - }, z.core.$loose>>; - resultType: z.ZodOptional; -}, z.core.$loose>; - -// @public -export class StdioServerTransport implements Transport { - constructor(_stdin?: Readable, _stdout?: Writable, options?: { - maxBufferSize?: number; - }); - // (undocumented) - close(): Promise; - // (undocumented) - onclose?: () => void; - // (undocumented) - _ondata: (chunk: Buffer) => void; - // (undocumented) - onerror?: (error: Error) => void; - // (undocumented) - _onerror: (error: Error) => void; - // (undocumented) - onmessage?: (message: JSONRPCMessage) => void; - // (undocumented) - _onstdouterror: (error: Error) => void; - // (undocumented) - send(message: JSONRPCMessage): Promise; - start(): Promise; -} - -// @public -type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; - -// @public -interface Transport { - close(): Promise; - onclose?: (() => void) | undefined; - onerror?: ((error: Error) => void) | undefined; - onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; - send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; - sessionId?: string | undefined; - setProtocolVersion?: ((version: string) => void) | undefined; - setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; - start(): Promise; -} - -// @public -type TransportSendOptions = { - relatedRequestId?: RequestId | undefined; - resumptionToken?: string | undefined; - onresumptiontoken?: ((token: string) => void) | undefined; -}; - -// @public -type WireOnlyResultKey = 'resultType'; - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/server/etc/server.validators-ajv.api.md b/packages/server/etc/server.validators-ajv.api.md deleted file mode 100644 index 2c34e28c16..0000000000 --- a/packages/server/etc/server.validators-ajv.api.md +++ /dev/null @@ -1,1572 +0,0 @@ -## API Report File for "@modelcontextprotocol/server" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { JSONSchema } from 'json-schema-typed'; - -// @public (undocumented) -type AddedFormat = true | RegExp | FormatValidator | FormatDefinition | FormatDefinition | AsyncFormatDefinition | AsyncFormatDefinition; - -// @public (undocumented) -type AddedKeywordDefinition = KeywordDefinition & { - type: JSONType[]; - schemaType: JSONType[]; -}; - -// @public (undocumented) -export class Ajv extends Ajv$2 { - // (undocumented) - _addDefaultMetaSchema(): void; - // (undocumented) - _addVocabularies(): void; - // (undocumented) - defaultMeta(): string | AnySchemaObject | undefined; -} - -// @public (undocumented) -class Ajv$2 { - // (undocumented) - $dataMetaSchema(metaSchema: AnySchemaObject, keywordsJsonPointers: string[]): AnySchemaObject; - constructor(opts?: Options); - // (undocumented) - _addDefaultMetaSchema(): void; - // (undocumented) - addFormat(name: string, format: Format): Ajv$2; - // (undocumented) - addKeyword(kwdOrDef: string | KeywordDefinition, def?: KeywordDefinition): Ajv$2; - // (undocumented) - addMetaSchema(schema: AnySchemaObject, key?: string, - // schema key - _validateSchema?: boolean | "log"): Ajv$2; - // (undocumented) - addSchema(schema: AnySchema | AnySchema[], - // If array is passed, `key` will be ignored - key?: string, - // Optional schema key. Can be passed to `validate` method instead of schema object or id/ref. One schema per instance can have empty `id` and `key`. - _meta?: boolean, - // true if schema is a meta-schema. Used internally, addMetaSchema should be used instead. - _validateSchema?: boolean | "log"): Ajv$2; - // (undocumented) - _addSchema(schema: AnySchema, meta?: boolean, baseId?: string, validateSchema?: boolean | "log", addSchema?: boolean): SchemaEnv; - // (undocumented) - _addVocabularies(): void; - // (undocumented) - addVocabulary(definitions: Vocabulary): Ajv$2; - // (undocumented) - readonly _compilations: Set; - // (undocumented) - compile(schema: Schema | JSONSchemaType, _meta?: boolean): ValidateFunction; - // (undocumented) - compile(schema: JTDSchemaType, _meta?: boolean): ValidateFunction; - // (undocumented) - compile(schema: T, _meta?: boolean): ValidateFunction>; - // (undocumented) - compile(schema: AsyncSchema, _meta?: boolean): AsyncValidateFunction; - // (undocumented) - compile(schema: AnySchema, _meta?: boolean): AnyValidateFunction; - // (undocumented) - compileAsync(schema: SchemaObject | JSONSchemaType, _meta?: boolean): Promise>; - // (undocumented) - compileAsync(schema: JTDSchemaType, _meta?: boolean): Promise>; - // (undocumented) - compileAsync(schema: AsyncSchema, meta?: boolean): Promise>; - // (undocumented) - compileAsync(schema: AnySchemaObject, meta?: boolean): Promise>; - // (undocumented) - defaultMeta(): string | AnySchemaObject | undefined; - // (undocumented) - errors?: ErrorObject[] | null; - // (undocumented) - errorsText(errors?: ErrorObject[] | null | undefined, - // optional array of validation errors - input?: ErrorsTextOptions): string; - // (undocumented) - readonly formats: { [Name in string]?: AddedFormat }; - // (undocumented) - getKeyword(keyword: string): AddedKeywordDefinition | boolean; - // (undocumented) - getSchema(keyRef: string): AnyValidateFunction | undefined; - // (undocumented) - logger: Logger; - // (undocumented) - static MissingRefError: typeof MissingRefError; - // (undocumented) - opts: InstanceOptions; - // (undocumented) - readonly refs: { [Ref in string]?: SchemaEnv | string }; - // (undocumented) - removeKeyword(keyword: string): Ajv$2; - // (undocumented) - removeSchema(schemaKeyRef?: AnySchema | string | RegExp): Ajv$2; - // (undocumented) - readonly RULES: ValidationRules; - // (undocumented) - readonly schemas: { [Key in string]?: SchemaEnv }; - // (undocumented) - readonly scope: ValueScope; - // (undocumented) - validate(schema: Schema | string, data: unknown): boolean; - // (undocumented) - validate(schemaKeyRef: AnySchema | string, data: unknown): boolean | Promise; - // (undocumented) - validate(schema: Schema | JSONSchemaType | string, data: unknown): data is T; - // (undocumented) - validate(schema: JTDSchemaType, data: unknown): data is T; - // (undocumented) - validate(schema: T, data: unknown): data is JTDDataType; - // (undocumented) - validate(schema: AsyncSchema, data: unknown | T): Promise; - // (undocumented) - validate(schemaKeyRef: AnySchema | string, data: unknown): data is T | Promise; - // (undocumented) - validateSchema(schema: AnySchema, throwOrLogError?: boolean): boolean | Promise; - // (undocumented) - static ValidationError: typeof ValidationError; -} - -// @public -export class AjvJsonSchemaValidator implements jsonSchemaValidator { - constructor(ajv?: AjvLike); - // (undocumented) - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -interface AjvLike { - // (undocumented) - compile: (schema: unknown) => AjvValidateFunction; - // (undocumented) - errorsText: (errors?: any) => string; - // (undocumented) - getSchema: (keyRef: string) => AjvValidateFunction | undefined; -} - -// @public (undocumented) -interface AjvValidateFunction { - // (undocumented) - (input: unknown): boolean; - // (undocumented) - errors?: any; -} - -// @public (undocumented) -type AnySchema = Schema | AsyncSchema; - -// @public (undocumented) -type AnySchemaObject = SchemaObject | AsyncSchema; - -// @public (undocumented) -type AnyValidateFunction = ValidateFunction | AsyncValidateFunction; - -// @public (undocumented) -interface AsyncFormatDefinition { - // (undocumented) - async: true; - // (undocumented) - compare?: FormatCompare; - // (undocumented) - type?: T extends string ? "string" | undefined : "number"; - // (undocumented) - validate: AsyncFormatValidator; -} - -// @public (undocumented) -type AsyncFormatValidator = (data: T) => Promise; - -// @public (undocumented) -interface AsyncSchema extends _SchemaObject { - // (undocumented) - $async: true; -} - -// @public (undocumented) -interface AsyncValidateFunction extends ValidateFunction { - // (undocumented) - $async: true; - // (undocumented) - (...args: Parameters>): Promise; -} - -// @public (undocumented) -type Block = Code | (() => void); - -// @public (undocumented) -type Code = _Code | Name; - -// @public (undocumented) -class CodeGen { - constructor(extScope: ValueScope, opts?: CodeGenOptions); - // (undocumented) - add(lhs: Code, rhs: SafeExpr): CodeGen; - // (undocumented) - assign(lhs: Code, rhs: SafeExpr, sideEffects?: boolean): CodeGen; - // (undocumented) - block(body?: Block, nodeCount?: number): CodeGen; - // (undocumented) - break(label?: Code): CodeGen; - // (undocumented) - code(c: Block | SafeExpr): CodeGen; - // (undocumented) - const(nameOrPrefix: Name | string, rhs: SafeExpr, _constant?: boolean): Name; - // (undocumented) - else(): CodeGen; - // (undocumented) - elseIf(condition: Code | boolean): CodeGen; - // (undocumented) - endBlock(nodeCount?: number): CodeGen; - // (undocumented) - endFor(): CodeGen; - // (undocumented) - endFunc(): CodeGen; - // (undocumented) - endIf(): CodeGen; - // (undocumented) - readonly _extScope: ValueScope; - // (undocumented) - for(iteration: Code, forBody?: Block): CodeGen; - // (undocumented) - forIn(nameOrPrefix: Name | string, obj: Code, forBody: (item: Name) => void, varKind?: Code): CodeGen; - // (undocumented) - forOf(nameOrPrefix: Name | string, iterable: Code, forBody: (item: Name) => void, varKind?: Code): CodeGen; - // (undocumented) - forRange(nameOrPrefix: Name | string, from: SafeExpr, to: SafeExpr, forBody: (index: Name) => void, varKind?: Code): CodeGen; - // (undocumented) - func(name: Name, args?: Code, async?: boolean, funcBody?: Block): CodeGen; - // (undocumented) - getScopeValue(prefix: string, keyOrRef: unknown): ValueScopeName | undefined; - // (undocumented) - if(condition: Code | boolean, thenBody?: Block, elseBody?: Block): CodeGen; - // (undocumented) - label(label: Name): CodeGen; - // (undocumented) - let(nameOrPrefix: Name | string, rhs?: SafeExpr, _constant?: boolean): Name; - // (undocumented) - name(prefix: string): Name; - // (undocumented) - object(...keyValues: [Name | string, SafeExpr | string][]): _Code; - // (undocumented) - optimize(n?: number): void; - // (undocumented) - return(value: Block | SafeExpr): CodeGen; - // (undocumented) - readonly _scope: Scope; - // (undocumented) - scopeCode(): Code; - // (undocumented) - scopeName(prefix: string): ValueScopeName; - // (undocumented) - scopeRefs(scopeName: Name): Code; - // (undocumented) - scopeValue(prefixOrName: ValueScopeName | string, value: NameValue): Name; - // (undocumented) - throw(error: Code): CodeGen; - // (undocumented) - toString(): string; - // (undocumented) - try(tryBody: Block, catchCode?: (e: Name) => void, finallyCode?: Block): CodeGen; - // (undocumented) - readonly _values: ScopeValueSets; - // (undocumented) - var(nameOrPrefix: Name | string, rhs?: SafeExpr, _constant?: boolean): Name; -} - -// @public (undocumented) -interface CodeGenOptions { - // (undocumented) - es5?: boolean; - // (undocumented) - lines?: boolean; - // (undocumented) - ownProperties?: boolean; -} - -// @public (undocumented) -type CodeItem = Name | string | number | boolean | null; - -// @public (undocumented) -interface CodeKeywordDefinition extends _KeywordDef { - // (undocumented) - code: (cxt: KeywordCxt, ruleType?: string) => void; - // (undocumented) - trackErrors?: boolean; -} - -// @public (undocumented) -interface CodeOptions { - // (undocumented) - es5?: boolean; - // (undocumented) - esm?: boolean; - // (undocumented) - formats?: Code; - // (undocumented) - lines?: boolean; - // (undocumented) - optimize?: boolean | number; - // (undocumented) - process?: (code: string, schema?: SchemaEnv) => string; - // (undocumented) - regExp?: RegExpEngine; - // (undocumented) - source?: boolean; -} - -// @public (undocumented) -type CompileKeywordFunc = (schema: any, parentSchema: AnySchemaObject, it: SchemaObjCxt) => DataValidateFunction; - -// @public (undocumented) -interface CurrentOptions { - // (undocumented) - $comment?: true | ((comment: string, schemaPath?: string, rootSchema?: AnySchemaObject) => unknown); - // (undocumented) - $data?: boolean; - // (undocumented) - addUsedSchema?: boolean; - // (undocumented) - allErrors?: boolean; - // (undocumented) - allowDate?: boolean; - // (undocumented) - allowMatchingProperties?: boolean; - // (undocumented) - allowUnionTypes?: boolean; - // (undocumented) - code?: CodeOptions; - // (undocumented) - coerceTypes?: boolean | "array"; - // (undocumented) - defaultMeta?: string | AnySchemaObject; - // (undocumented) - discriminator?: boolean; - // (undocumented) - dynamicRef?: boolean; - // (undocumented) - formats?: { [Name in string]?: Format }; - // (undocumented) - inlineRefs?: boolean | number; - // (undocumented) - int32range?: boolean; - // (undocumented) - jtd?: boolean; - // (undocumented) - keywords?: Vocabulary; - // (undocumented) - loadSchema?: (uri: string) => Promise; - // (undocumented) - logger?: Logger | false; - // (undocumented) - loopEnum?: number; - // (undocumented) - loopRequired?: number; - // (undocumented) - messages?: boolean; - // (undocumented) - meta?: SchemaObject | boolean; - // (undocumented) - multipleOfPrecision?: number; - // (undocumented) - next?: boolean; - // (undocumented) - ownProperties?: boolean; - // (undocumented) - parseDate?: boolean; - // (undocumented) - passContext?: boolean; - // (undocumented) - removeAdditional?: boolean | "all" | "failing"; - // (undocumented) - schemaId?: "id" | "$id"; - // (undocumented) - schemas?: AnySchema[] | { [Key in string]?: AnySchema }; - // (undocumented) - specialNumbers?: "fast" | "null"; - // (undocumented) - strict?: boolean | "log"; - // (undocumented) - strictNumbers?: boolean | "log"; - // (undocumented) - strictRequired?: boolean | "log"; - // (undocumented) - strictSchema?: boolean | "log"; - // (undocumented) - strictTuples?: boolean | "log"; - // (undocumented) - strictTypes?: boolean | "log"; - // (undocumented) - timestamp?: "string" | "date"; - // (undocumented) - unevaluated?: boolean; - // (undocumented) - unicodeRegExp?: boolean; - // (undocumented) - uriResolver?: UriResolver; - // (undocumented) - useDefaults?: boolean | "empty"; - // (undocumented) - validateFormats?: boolean; - // (undocumented) - validateSchema?: boolean | "log"; - // (undocumented) - verbose?: boolean; -} - -// @public (undocumented) -interface DataValidateFunction { - // (undocumented) - (...args: Parameters): boolean | Promise; - // (undocumented) - errors?: Partial[]; -} - -// @public (undocumented) -interface DataValidationCxt { - // (undocumented) - dynamicAnchors: { [Ref in string]?: ValidateFunction }; - // (undocumented) - instancePath: string; - // (undocumented) - parentData: { [K in T]: any }; - // (undocumented) - parentDataProperty: T; - // (undocumented) - rootData: Record | any[]; -} - -// @public (undocumented) -interface DeprecatedOptions { - // @deprecated (undocumented) - ignoreKeywordsWithRef?: boolean; - // @deprecated (undocumented) - jsPropertySyntax?: boolean; - // @deprecated (undocumented) - unicode?: boolean; -} - -// @public -type EnumString = [T] extends [never] ? null : T extends string ? string extends T ? null : T : null; - -// @public (undocumented) -interface ErrorObject, S = unknown> { - // (undocumented) - data?: unknown; - // (undocumented) - instancePath: string; - // (undocumented) - keyword: K; - // (undocumented) - message?: string; - // (undocumented) - params: P; - // (undocumented) - parentSchema?: AnySchemaObject; - // (undocumented) - propertyName?: string; - // (undocumented) - schema?: S; - // (undocumented) - schemaPath: string; -} - -// @public (undocumented) -interface ErrorPaths { - // (undocumented) - instancePath?: Code; - // (undocumented) - parentSchema?: boolean; - // (undocumented) - schemaPath?: string; -} - -// @public (undocumented) -interface ErrorsTextOptions { - // (undocumented) - dataVar?: string; - // (undocumented) - separator?: string; -} - -// @public (undocumented) -interface Evaluated { - // (undocumented) - dynamicItems: boolean; - // (undocumented) - dynamicProps: boolean; - // (undocumented) - items?: EvaluatedItems; - // (undocumented) - props?: EvaluatedProperties; -} - -// @public (undocumented) -type EvaluatedItems = number | true; - -// @public (undocumented) -type EvaluatedProperties = { [K in string]?: true } | true; - -// @public (undocumented) -type Format = AddedFormat | string; - -// @public (undocumented) -type FormatCompare = (data1: T, data2: T) => number | undefined; - -// @public (undocumented) -interface FormatDefinition { - // (undocumented) - async?: false | undefined; - // (undocumented) - compare?: FormatCompare; - // (undocumented) - type?: T extends string ? "string" | undefined : "number"; - // (undocumented) - validate: FormatValidator | (T extends string ? string | RegExp : never); -} - -// @public (undocumented) -type FormatMode = "fast" | "full"; - -// @public (undocumented) -type FormatName = "date" | "time" | "date-time" | "iso-time" | "iso-date-time" | "duration" | "uri" | "uri-reference" | "uri-template" | "url" | "email" | "hostname" | "ipv4" | "ipv6" | "regex" | "uuid" | "json-pointer" | "json-pointer-uri-fragment" | "relative-json-pointer" | "byte" | "int32" | "int64" | "float" | "double" | "password" | "binary"; - -// @public (undocumented) -interface FormatOptions { - // (undocumented) - formats?: FormatName[]; - // (undocumented) - keywords?: boolean; - // (undocumented) - mode?: FormatMode; -} - -// @public (undocumented) -type FormatValidator = (data: T) => boolean; - -// @public (undocumented) -interface FormatsPlugin extends Plugin_2 { - // (undocumented) - get: (format: FormatName, mode?: FormatMode) => Format; -} - -// @public (undocumented) -type FormatsPluginOptions = FormatName[] | FormatOptions; - -// @public (undocumented) -interface FuncKeywordDefinition extends _KeywordDef { - // (undocumented) - async?: boolean; - // (undocumented) - compile?: CompileKeywordFunc; - // (undocumented) - errors?: boolean | "full"; - // (undocumented) - modifying?: boolean; - // (undocumented) - schema?: boolean; - // (undocumented) - valid?: boolean; - // (undocumented) - validate?: SchemaValidateFunction | DataValidateFunction; -} - -// @public (undocumented) -interface InstanceCodeOptions extends CodeOptions { - // (undocumented) - optimize: number; - // (undocumented) - regExp: RegExpEngine; -} - -// @public (undocumented) -type InstanceOptions = Options & RequiredInstanceOptions; - -// @public -type IsElements = false extends IsUnion ? [T] extends [readonly unknown[]] ? undefined extends T[0.5] ? false : true : false : false; - -// @public -type IsEmptyRecord = [T] extends [Record] ? [T] extends [never] ? false : true : false; - -// @public -type IsEnum = null extends EnumString ? false : true; - -// @public -type IsRecord = Union extends IsUnion ? null extends EnumString ? false : true : false; - -// @public (undocumented) -type IsUnion = IsUnion_; - -// @public -type IsUnion_ = false extends (T extends unknown ? ([U] extends [T] ? false : true) : never) ? false : true; - -// @public -type IsValues = false extends IsUnion ? TypeEquality : false; - -// @public (undocumented) -type JSONSchemaType = StrictNullChecksWrapper<"JSONSchemaType", UncheckedJSONSchemaType>; - -// @public (undocumented) -type JSONType = (typeof _jsonTypes)[number]; - -// @public (undocumented) -type JSONType$2 = IsPartial extends true ? T | undefined : T; - -// @public (undocumented) -type JTDDataDef> = -// ref -(S extends { - ref: string; -} ? D extends { [K in S["ref"]]: infer V } ? JTDDataDef : never : S extends { - type: NumberType; -} ? number : S extends { - type: "boolean"; -} ? boolean : S extends { - type: "string"; -} ? string : S extends { - type: "timestamp"; -} ? string | Date : S extends { - enum: readonly (infer E)[]; -} ? string extends E ? never : [E] extends [string] ? E : never : S extends { - elements: infer E; -} ? JTDDataDef[] : S extends { - properties: Record; - optionalProperties?: Record; - additionalProperties?: boolean; -} ? { -readonly [K in keyof S["properties"]]-?: JTDDataDef } & { -readonly [K in keyof S["optionalProperties"]]+?: JTDDataDef } & ([S["additionalProperties"]] extends [true] ? Record : unknown) : S extends { - properties?: Record; - optionalProperties: Record; - additionalProperties?: boolean; -} ? { -readonly [K in keyof S["properties"]]-?: JTDDataDef } & { -readonly [K in keyof S["optionalProperties"]]+?: JTDDataDef } & ([S["additionalProperties"]] extends [true] ? Record : unknown) : S extends { - values: infer V; -} ? Record> : S extends { - discriminator: infer M; - mapping: Record; -} ? [M] extends [string] ? { [K in keyof S["mapping"]]: JTDDataDef & { [KM in M]: K } }[keyof S["mapping"]] : never : unknown) | (S extends { - nullable: true; -} ? null : never); - -// @public (undocumented) -type JTDDataType = S extends { - definitions: Record; -} ? JTDDataDef : JTDDataDef>; - -// @public -type JTDSchemaType = Record> = ( -// refs - where null wasn't specified, must match exactly -(null extends EnumString ? never : ({ [K in keyof D]: [T] extends [D[K]] ? { - ref: K; - } : never }[keyof D] & { - nullable?: false; -}) | (null extends T ? { [K in keyof D]: [Exclude] extends [Exclude] ? { - ref: K; - } : never }[keyof D] & { - nullable: true; -} : never)) | (unknown extends T ? { - nullable?: boolean; -} : never) | ((true extends NullTypeEquality ? { - type: NumberType; -} : true extends NullTypeEquality ? { - type: "boolean"; -} : true extends NullTypeEquality ? { - type: StringType; -} : true extends NullTypeEquality ? { - type: "timestamp"; -} : true extends IsEnum> ? { - enum: EnumString>[]; -} : true extends IsElements> ? T extends readonly (infer E)[] ? { - elements: JTDSchemaType; -} : never : true extends IsEmptyRecord> ? { - properties: Record; - optionalProperties?: Record; -} | { - optionalProperties: Record; -} : true extends IsValues> ? T extends Record ? { - values: JTDSchemaType; -} : never : true extends IsRecord, false> ? ([RequiredKeys>] extends [never] ? { - properties?: Record; -} : { - properties: { [K in RequiredKeys]: JTDSchemaType }; -}) & ([OptionalKeys>] extends [never] ? { - optionalProperties?: Record; -} : { - optionalProperties: { [K in OptionalKeys]: JTDSchemaType, D> }; -}) & { - additionalProperties?: boolean; -} : true extends IsRecord, true> ? { [K in keyof Exclude]-?: Exclude[K] extends string ? { - discriminator: K; - mapping: { [M in Exclude[K]]: JTDSchemaType ? T : never, K>, D> }; - } : never }[keyof Exclude] : never) & (null extends T ? { - nullable: true; -} : { - nullable?: false; -}))) & { - metadata?: Record; - definitions?: { [K in keyof D]: JTDSchemaType }; -}; - -// @public -type JsonSchemaType = JSONSchema.Interface; - -// @public -type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public (undocumented) -class KeywordCxt implements KeywordErrorCxt { - // (undocumented) - readonly $data?: string | false; - // (undocumented) - $dataError(): void; - constructor(it: SchemaObjCxt, def: AddedKeywordDefinition, keyword: string); - // (undocumented) - readonly allErrors?: boolean; - // (undocumented) - block$data(valid: Name, codeBlock: () => void, $dataValid?: Code): void; - // (undocumented) - check$data(valid?: Name, $dataValid?: Code): void; - // (undocumented) - readonly data: Name; - // (undocumented) - readonly def: AddedKeywordDefinition; - // (undocumented) - error(append?: boolean, errorParams?: KeywordCxtParams, errorPaths?: ErrorPaths): void; - // (undocumented) - readonly errsCount?: Name; - // (undocumented) - fail$data(condition: Code): void; - // (undocumented) - fail(condition?: Code): void; - // (undocumented) - failResult(condition: Code, successAction?: () => void, failAction?: () => void): void; - // (undocumented) - readonly gen: CodeGen; - // (undocumented) - invalid$data(): Code; - // (undocumented) - readonly it: SchemaObjCxt; - // (undocumented) - readonly keyword: string; - // (undocumented) - mergeEvaluated(schemaCxt: SchemaCxt, toName?: typeof Name): void; - // (undocumented) - mergeValidEvaluated(schemaCxt: SchemaCxt, valid: Name): boolean | void; - // (undocumented) - ok(cond: Code | boolean): void; - // (undocumented) - params: KeywordCxtParams; - // (undocumented) - readonly parentSchema: AnySchemaObject; - // (undocumented) - pass(condition: Code, failAction?: () => void): void; - // (undocumented) - reset(): void; - // (undocumented) - result(condition: Code, successAction?: () => void, failAction?: () => void): void; - // (undocumented) - schema: any; - // (undocumented) - readonly schemaCode: Code | number | boolean; - // (undocumented) - readonly schemaType: JSONType[]; - // (undocumented) - readonly schemaValue: Code | number | boolean; - // (undocumented) - setParams(obj: KeywordCxtParams, assign?: true): void; - // (undocumented) - subschema(appl: SubschemaArgs, valid: Name): SchemaCxt; -} - -// @public (undocumented) -type KeywordCxtParams = { [P in string]?: Code | string | number }; - -// @public (undocumented) -type KeywordDefinition = CodeKeywordDefinition | FuncKeywordDefinition | MacroKeywordDefinition; - -// @public (undocumented) -interface KeywordErrorCxt { - // (undocumented) - $data?: string | false; - // (undocumented) - data: Name; - // (undocumented) - errsCount?: Name; - // (undocumented) - gen: CodeGen; - // (undocumented) - it: SchemaCxt; - // (undocumented) - keyword: string; - // (undocumented) - params: KeywordCxtParams; - // (undocumented) - parentSchema?: AnySchemaObject; - // (undocumented) - schema: any; - // (undocumented) - schemaCode: Code | number | boolean; - // (undocumented) - schemaType?: JSONType[]; - // (undocumented) - schemaValue: Code | number | boolean; -} - -// @public (undocumented) -interface KeywordErrorDefinition { - // (undocumented) - message: string | Code | ((cxt: KeywordErrorCxt) => string | Code); - // (undocumented) - params?: Code | ((cxt: KeywordErrorCxt) => Code); -} - -// @public (undocumented) -type Known = { - [key: string]: Known; -} | [Known, ...Known[]] | Known[] | number | string | boolean | null; - -// @public (undocumented) -type LocalRefs = { [Ref in string]?: AnySchemaObject }; - -// @public (undocumented) -interface Logger { - // (undocumented) - error(...args: unknown[]): unknown; - // (undocumented) - log(...args: unknown[]): unknown; - // (undocumented) - warn(...args: unknown[]): unknown; -} - -// @public (undocumented) -interface MacroKeywordDefinition extends FuncKeywordDefinition { - // (undocumented) - macro: MacroKeywordFunc; -} - -// @public (undocumented) -type MacroKeywordFunc = (schema: any, parentSchema: AnySchemaObject, it: SchemaCxt) => AnySchema; - -// @public (undocumented) -class MissingRefError extends Error { - constructor(resolver: UriResolver, baseId: string, ref: string, msg?: string); - // (undocumented) - readonly missingRef: string; - // (undocumented) - readonly missingSchema: string; -} - -// @public (undocumented) -class Name extends _CodeOrName { - constructor(s: string); - // (undocumented) - emptyStr(): boolean; - // (undocumented) - get names(): UsedNames; - // (undocumented) - readonly str: string; - // (undocumented) - toString(): string; -} - -// @public (undocumented) -interface NameGroup { - // (undocumented) - index: number; - // (undocumented) - prefix: string; -} - -// @public (undocumented) -interface NameValue { - // (undocumented) - code?: Code; - // (undocumented) - key?: unknown; - // (undocumented) - ref: ValueReference; -} - -// @public -type NullTypeEquality = TypeEquality; - -// @public (undocumented) -type Nullable = undefined extends T ? { - nullable: true; - const?: null; - enum?: readonly (T | null)[]; - default?: T | null; -} : { - nullable?: false; - const?: T; - enum?: readonly T[]; - default?: T; -}; - -// @public (undocumented) -interface NumberKeywords { - // (undocumented) - exclusiveMaximum?: number; - // (undocumented) - exclusiveMinimum?: number; - // (undocumented) - format?: string; - // (undocumented) - maximum?: number; - // (undocumented) - minimum?: number; - // (undocumented) - multipleOf?: number; -} - -// @public -type NumberType = "float32" | "float64" | "int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32"; - -// @public -type OptionalKeys = { [K in keyof T]-?: undefined extends T[K] ? K : never }[keyof T]; - -// @public (undocumented) -type Options = CurrentOptions & DeprecatedOptions; - -// @public (undocumented) -interface Plugin_2 { - // (undocumented) - (ajv: Ajv$2, options?: Opts): Ajv$2; - // (undocumented) - [prop: string]: any; -} - -// @public (undocumented) -interface RegExpEngine { - // (undocumented) - (pattern: string, u: string): RegExpLike; - // (undocumented) - code: string; -} - -// @public (undocumented) -interface RegExpLike { - // (undocumented) - test: (s: string) => boolean; -} - -// @public (undocumented) -type RequiredInstanceOptions = { [K in "strictSchema" | "strictNumbers" | "strictTypes" | "strictTuples" | "strictRequired" | "inlineRefs" | "loopRequired" | "loopEnum" | "meta" | "messages" | "schemaId" | "addUsedSchema" | "validateSchema" | "validateFormats" | "int32range" | "unicodeRegExp" | "uriResolver"]: NonNullable } & { - code: InstanceCodeOptions; -}; - -// @public -type RequiredKeys = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]; - -// @public (undocumented) -interface Rule { - // (undocumented) - definition: AddedKeywordDefinition; - // (undocumented) - keyword: string; -} - -// @public (undocumented) -interface RuleGroup { - // (undocumented) - rules: Rule[]; - // (undocumented) - type?: JSONType; -} - -// @public (undocumented) -type SafeExpr = Code | number | boolean | null; - -// @public (undocumented) -type Schema = SchemaObject | boolean; - -// @public (undocumented) -interface SchemaCxt { - // (undocumented) - readonly allErrors?: boolean; - // (undocumented) - baseId: string; - // (undocumented) - readonly compositeRule?: boolean; - // (undocumented) - readonly createErrors?: boolean; - // (undocumented) - readonly data: Name; - // (undocumented) - readonly dataLevel: number; - // (undocumented) - readonly dataNames: Name[]; - // (undocumented) - readonly dataPathArr: (Code | number)[]; - // (undocumented) - dataTypes: JSONType[]; - // (undocumented) - definedProperties: Set; - // (undocumented) - readonly errorPath: Code; - // (undocumented) - readonly errSchemaPath: string; - // (undocumented) - evaluated?: Name; - // (undocumented) - readonly gen: CodeGen; - // (undocumented) - items?: EvaluatedItems | Name; - // (undocumented) - jtdDiscriminator?: string; - // (undocumented) - jtdMetadata?: boolean; - // (undocumented) - readonly opts: InstanceOptions; - // (undocumented) - readonly parentData: Name; - // (undocumented) - readonly parentDataProperty: Code | number; - // (undocumented) - readonly propertyName?: Name; - // (undocumented) - props?: EvaluatedProperties | Name; - // (undocumented) - readonly rootId: string; - // (undocumented) - readonly schema: AnySchema; - // (undocumented) - readonly schemaEnv: SchemaEnv; - // (undocumented) - readonly schemaPath: Code; - // (undocumented) - readonly self: Ajv$2; - // (undocumented) - readonly topSchemaRef: Code; - // (undocumented) - readonly validateName: Name; - // (undocumented) - readonly ValidationError?: Name; -} - -// @public (undocumented) -class SchemaEnv implements SchemaEnvArgs { - // (undocumented) - readonly $async?: boolean; - constructor(env: SchemaEnvArgs); - // (undocumented) - baseId: string; - // (undocumented) - readonly dynamicAnchors: { [Ref in string]?: true }; - // (undocumented) - localRefs?: LocalRefs; - // (undocumented) - readonly meta?: boolean; - // (undocumented) - parse?: (data: string) => unknown; - // (undocumented) - parseName?: ValueScopeName; - // (undocumented) - readonly refs: SchemaRefs; - // (undocumented) - readonly root: SchemaEnv; - // (undocumented) - readonly schema: AnySchema; - // (undocumented) - readonly schemaId?: "$id" | "id"; - // (undocumented) - schemaPath?: string; - // (undocumented) - serialize?: (data: unknown) => string; - // (undocumented) - serializeName?: ValueScopeName; - // (undocumented) - validate?: AnyValidateFunction; - // (undocumented) - validateName?: ValueScopeName; -} - -// @public (undocumented) -interface SchemaEnvArgs { - // (undocumented) - readonly baseId?: string; - // (undocumented) - readonly localRefs?: LocalRefs; - // (undocumented) - readonly meta?: boolean; - // (undocumented) - readonly root?: SchemaEnv; - // (undocumented) - readonly schema: AnySchema; - // (undocumented) - readonly schemaId?: "$id" | "id"; - // (undocumented) - readonly schemaPath?: string; -} - -// @public (undocumented) -interface SchemaObjCxt extends SchemaCxt { - // (undocumented) - readonly schema: AnySchemaObject; -} - -// @public (undocumented) -interface SchemaObject extends _SchemaObject { - // (undocumented) - $async?: false; - // (undocumented) - $id?: string; - // (undocumented) - $schema?: string; - // (undocumented) - [x: string]: any; - // (undocumented) - id?: string; -} - -// @public (undocumented) -type SchemaRefs = { [Ref in string]?: SchemaEnv | AnySchema }; - -// @public (undocumented) -interface SchemaValidateFunction { - // (undocumented) - (schema: any, data: any, parentSchema?: AnySchemaObject, dataCxt?: DataValidationCxt): boolean | Promise; - // (undocumented) - errors?: Partial[]; -} - -// @public (undocumented) -class Scope { - constructor(input?: ScopeOptions); - // (undocumented) - name(prefix: string): Name; - // (undocumented) - protected readonly _names: { [Prefix in string]?: NameGroup }; - // (undocumented) - protected _newName(prefix: string): string; - // (undocumented) - protected readonly _parent?: Scope; - // (undocumented) - protected readonly _prefixes?: Set; - // (undocumented) - toName(nameOrPrefix: Name | string): Name; -} - -// @public (undocumented) -interface ScopeOptions { - // (undocumented) - parent?: Scope; - // (undocumented) - prefixes?: Set; -} - -// @public (undocumented) -interface ScopePath { - // (undocumented) - itemIndex: number; - // (undocumented) - property: string; -} - -// @public (undocumented) -type ScopeStore = Record; - -// @public (undocumented) -type ScopeValueSets = { [Prefix in string]?: Set }; - -// @public (undocumented) -type ScopeValues = { [Prefix in string]?: Map }; - -// @public -type SomeJTDSchemaType = ( -// ref - { - ref: string; -} | { - type: NumberType | StringType | "boolean"; -} | { - enum: string[]; -} | { - elements: SomeJTDSchemaType; -} | { - values: SomeJTDSchemaType; -} | { - properties: Record; - optionalProperties?: Record; - additionalProperties?: boolean; -} | { - properties?: Record; - optionalProperties: Record; - additionalProperties?: boolean; -} | { - discriminator: string; - mapping: Record; -} | {}) & { - nullable?: boolean; - metadata?: Record; - definitions?: Record; -}; - -// @public (undocumented) -interface SourceCode { - // (undocumented) - evaluated?: Code; - // (undocumented) - scopeValues: ScopeValueSets; - // (undocumented) - validateCode: string; - // (undocumented) - validateName: ValueScopeName; -} - -// @public (undocumented) -type StrictNullChecksWrapper = undefined extends null ? `strictNullChecks must be true in tsconfig to use ${Name}` : Type; - -// @public (undocumented) -interface StringKeywords { - // (undocumented) - format?: string; - // (undocumented) - maxLength?: number; - // (undocumented) - minLength?: number; - // (undocumented) - pattern?: string; -} - -// @public -type StringType = "string" | "timestamp"; - -// @public (undocumented) -type SubschemaArgs = Partial<{ - keyword: string; - schemaProp: string | number; - schema: AnySchema; - schemaPath: Code; - errSchemaPath: string; - topSchemaRef: Code; - data: Name | Code; - dataProp: Code | string | number; - dataTypes: JSONType[]; - definedProperties: Set; - propertyName: Name; - dataPropType: Type; - jtdDiscriminator: string; - jtdMetadata: boolean; - compositeRule: true; - createErrors: boolean; - allErrors: boolean; -}>; - -// @public (undocumented) -enum Type { - // (undocumented) - Num = 0, - // (undocumented) - Str = 1, -} - -// @public -type TypeEquality = [T] extends [E] ? ([E] extends [T] ? true : false) : false; - -// @public (undocumented) -type UncheckedJSONSchemaType = ( -// these two unions allow arbitrary unions of types - { - anyOf: readonly UncheckedJSONSchemaType[]; -} | { - oneOf: readonly UncheckedJSONSchemaType[]; -} | ({ - type: readonly (T extends number ? JSONType$2<"number" | "integer", IsPartial> : T extends string ? JSONType$2<"string", IsPartial> : T extends boolean ? JSONType$2<"boolean", IsPartial> : never)[]; -} & UnionToIntersection) | ((T extends number ? { - type: JSONType$2<"number" | "integer", IsPartial>; -} & NumberKeywords : T extends string ? { - type: JSONType$2<"string", IsPartial>; -} & StringKeywords : T extends boolean ? { - type: JSONType$2<"boolean", IsPartial>; -} : T extends readonly [any, ...any[]] ? { - type: JSONType$2<"array", IsPartial>; - items: { readonly [K in keyof T]-?: UncheckedJSONSchemaType & Nullable } & { - length: T["length"]; - }; - minItems: T["length"]; -} & ({ - maxItems: T["length"]; -} | { - additionalItems: false; -}) : T extends readonly any[] ? { - type: JSONType$2<"array", IsPartial>; - items: UncheckedJSONSchemaType; - contains?: UncheckedPartialSchema; - minItems?: number; - maxItems?: number; - minContains?: number; - maxContains?: number; - uniqueItems?: true; - additionalItems?: never; -} : T extends Record ? { - type: JSONType$2<"object", IsPartial>; - additionalProperties?: boolean | UncheckedJSONSchemaType; - unevaluatedProperties?: boolean | UncheckedJSONSchemaType; - properties?: IsPartial extends true ? Partial> : UncheckedPropertiesSchema; - patternProperties?: Record>; - propertyNames?: Omit, "type"> & { - type?: "string"; - }; - dependencies?: { [K in keyof T]?: readonly (keyof T)[] | UncheckedPartialSchema }; - dependentRequired?: { [K in keyof T]?: readonly (keyof T)[] }; - dependentSchemas?: { [K in keyof T]?: UncheckedPartialSchema }; - minProperties?: number; - maxProperties?: number; -} & (IsPartial extends true ? { - required: readonly (keyof T)[]; -} : [UncheckedRequiredMembers] extends [never] ? { - required?: readonly UncheckedRequiredMembers[]; -} : { - required: readonly UncheckedRequiredMembers[]; -}) : T extends null ? { - type: JSONType$2<"null", IsPartial>; - nullable: true; -} : never) & { - allOf?: readonly UncheckedPartialSchema[]; - anyOf?: readonly UncheckedPartialSchema[]; - oneOf?: readonly UncheckedPartialSchema[]; - if?: UncheckedPartialSchema; - then?: UncheckedPartialSchema; - else?: UncheckedPartialSchema; - not?: UncheckedPartialSchema; -})) & { - [keyword: string]: any; - $id?: string; - $ref?: string; - $defs?: Record>; - definitions?: Record>; -}; - -// @public (undocumented) -type UncheckedPartialSchema = Partial>; - -// @public (undocumented) -type UncheckedPropertiesSchema = { [K in keyof T]-?: (UncheckedJSONSchemaType & Nullable) | { - $ref: string; - } }; - -// @public (undocumented) -type UncheckedRequiredMembers = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]; - -// @public (undocumented) -type UnionToIntersection = (U extends any ? (_: U) => void : never) extends ((_: infer I) => void) ? I : never; - -// @public (undocumented) -interface UriResolver { - // (undocumented) - parse(uri: string): URIComponent; - // (undocumented) - resolve(base: string, path: string): string; - // (undocumented) - serialize(component: URIComponent): string; -} - -// @public (undocumented) -type UsedNames = Record; - -// @public (undocumented) -type UsedScopeValues = { [Prefix in string]?: Map }; - -// @public (undocumented) -enum UsedValueState { - // (undocumented) - Completed = 1, - // (undocumented) - Started = 0, -} - -// @public (undocumented) -interface VSOptions extends ValueScopeOptions { - // (undocumented) - _n: Code; -} - -// @public (undocumented) -interface ValidateFunction { - // (undocumented) - (this: Ajv$2 | any, data: any, dataCxt?: DataValidationCxt): data is T; - // (undocumented) - errors?: null | ErrorObject[]; - // (undocumented) - evaluated?: Evaluated; - // (undocumented) - schema: AnySchema; - // (undocumented) - schemaEnv: SchemaEnv; - // (undocumented) - source?: SourceCode; -} - -// @public (undocumented) -class ValidationError extends Error { - constructor(errors: Partial[]); - // (undocumented) - readonly ajv: true; - // (undocumented) - readonly errors: Partial[]; - // (undocumented) - readonly validation: true; -} - -// @public (undocumented) -interface ValidationRules { - // (undocumented) - all: { [Key in string]?: boolean | Rule }; - // (undocumented) - keywords: { [Key in string]?: boolean }; - // (undocumented) - post: RuleGroup; - // (undocumented) - rules: RuleGroup[]; - // (undocumented) - types: ValidationTypes; -} - -// @public (undocumented) -type ValidationTypes = { [K in JSONType]: boolean | RuleGroup | undefined }; - -// @public (undocumented) -type ValueReference = unknown; - -// @public (undocumented) -class ValueScope extends Scope { - constructor(opts: ValueScopeOptions); - // (undocumented) - get(): ScopeStore; - // (undocumented) - getValue(prefix: string, keyOrRef: unknown): ValueScopeName | undefined; - // (undocumented) - name(prefix: string): ValueScopeName; - // (undocumented) - readonly opts: VSOptions; - // (undocumented) - protected readonly _scope: ScopeStore; - // (undocumented) - scopeCode(values?: ScopeValues | ScopeValueSets, usedValues?: UsedScopeValues, getCode?: (n: ValueScopeName) => Code | undefined): Code; - // (undocumented) - scopeRefs(scopeName: Name, values?: ScopeValues | ScopeValueSets): Code; - // (undocumented) - value(nameOrPrefix: ValueScopeName | string, value: NameValue): ValueScopeName; - // (undocumented) - protected readonly _values: ScopeValues; -} - -// @public (undocumented) -class ValueScopeName extends Name { - constructor(prefix: string, nameStr: string); - // (undocumented) - readonly prefix: string; - // (undocumented) - scopePath?: Code; - // (undocumented) - setValue(value: NameValue, input: ScopePath): void; - // (undocumented) - value?: NameValue; -} - -// @public (undocumented) -interface ValueScopeOptions extends ScopeOptions { - // (undocumented) - es5?: boolean; - // (undocumented) - lines?: boolean; - // (undocumented) - scope: ScopeStore; -} - -// @public (undocumented) -type Vocabulary = (KeywordDefinition | string)[]; - -// @public (undocumented) -class _Code extends _CodeOrName { - constructor(code: string | readonly CodeItem[]); - // (undocumented) - emptyStr(): boolean; - // (undocumented) - readonly _items: readonly CodeItem[]; - // (undocumented) - get names(): UsedNames; - // (undocumented) - get str(): string; - // (undocumented) - toString(): string; -} - -// @public (undocumented) -abstract class _CodeOrName { - // (undocumented) - abstract emptyStr(): boolean; - // (undocumented) - abstract readonly names: UsedNames; - // (undocumented) - abstract readonly str: string; - // (undocumented) - abstract toString(): string; -} - -// @public (undocumented) -interface _KeywordDef { - // (undocumented) - $data?: boolean; - // (undocumented) - $dataError?: KeywordErrorDefinition; - // (undocumented) - allowUndefined?: boolean; - // (undocumented) - before?: string; - // (undocumented) - dependencies?: string[]; - // (undocumented) - error?: KeywordErrorDefinition; - // (undocumented) - implements?: string[]; - // (undocumented) - keyword: string | string[]; - // (undocumented) - metaSchema?: AnySchemaObject; - // (undocumented) - post?: boolean; - // (undocumented) - schemaType?: JSONType | JSONType[]; - // (undocumented) - type?: JSONType | JSONType[]; - // (undocumented) - validateSchema?: AnyValidateFunction; -} - -// @public (undocumented) -interface _SchemaObject { - // (undocumented) - $id?: string; - // (undocumented) - $schema?: string; - // (undocumented) - [x: string]: any; - // (undocumented) - id?: string; -} - -// @public (undocumented) -const _jsonTypes: readonly ["string", "number", "integer", "boolean", "null", "object", "array"]; - -// @public -export const addFormats: typeof formatsPlugin.default; - -// @public (undocumented) -const formatsPlugin: FormatsPlugin; - -// @public -interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/server/etc/server.validators-cf-worker.api.md b/packages/server/etc/server.validators-cf-worker.api.md deleted file mode 100644 index 5f380d55b4..0000000000 --- a/packages/server/etc/server.validators-cf-worker.api.md +++ /dev/null @@ -1,44 +0,0 @@ -## API Report File for "@modelcontextprotocol/server" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { JSONSchema } from 'json-schema-typed'; - -// @public -export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { - constructor(options?: { - shortcircuit?: boolean; - draft?: CfWorkerSchemaDraft; - }); - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// @public -export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; - -// @public -type JsonSchemaType = JSONSchema.Interface; - -// @public -type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; - -// @public -type JsonSchemaValidatorResult = { - valid: true; - data: T; - errorMessage: undefined; -} | { - valid: false; - data: undefined; - errorMessage: string; -}; - -// @public -interface jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator; -} - -// (No @packageDocumentation comment for this package) -``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a77a804c8..9ffd38d3dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,9 +155,6 @@ importers: '@eslint/js': specifier: catalog:devTools version: 9.39.4 - '@microsoft/api-extractor': - specifier: 7.58.7 - version: 7.58.7(@types/node@24.12.0) '@modelcontextprotocol/client': specifier: workspace:^ version: link:packages/client @@ -2114,19 +2111,6 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@microsoft/api-extractor-model@7.33.8': - resolution: {integrity: sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==} - - '@microsoft/api-extractor@7.58.7': - resolution: {integrity: sha512-yK6OycD46gIzLRpj6ueVUWPk1ACSpkN1LBo05gY1qPTylbWyUCanXfH7+VgkI5LJrJoRSQR5F04XuCffCXLOBw==} - hasBin: true - - '@microsoft/tsdoc-config@0.18.1': - resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} - - '@microsoft/tsdoc@0.16.0': - resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} - '@modelcontextprotocol/conformance@0.2.0-alpha.3': resolution: {integrity: sha512-YjdEKaKWswkJtRl0G3RmZCfljkAct3je834sqGHgasGeU2eUp7sb+6sJL0uNEaAY3XXWYumN/mjr6aPZbnbJMA==} hasBin: true @@ -2578,36 +2562,6 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@rushstack/node-core-library@5.23.1': - resolution: {integrity: sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - - '@rushstack/problem-matcher@0.2.1': - resolution: {integrity: sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - - '@rushstack/rig-package@0.7.3': - resolution: {integrity: sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==} - - '@rushstack/terminal@0.24.0': - resolution: {integrity: sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ==} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - - '@rushstack/ts-command-line@5.3.9': - resolution: {integrity: sha512-GIHqU+sRGQ3LGWAZu1O+9Yh++qwtyNIIGuNbcWHJjBTm2qRez0cwINUHZ+pQLR8UuzZDcMajrDaNbUYoaL/XtQ==} - '@shikijs/engine-oniguruma@3.23.0': resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} @@ -2639,9 +2593,6 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/argparse@1.0.38': - resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} - '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -2978,14 +2929,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv-draft-04@1.0.0: - resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} - peerDependencies: - ajv: ^8.5.0 - peerDependenciesMeta: - ajv: - optional: true - ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -3430,10 +3373,6 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - diff@8.0.4: - resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} - engines: {node: '>=0.3.1'} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3839,10 +3778,6 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} - engines: {node: '>=14.14'} - fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -3991,10 +3926,6 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-lazy@4.0.0: - resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} - engines: {node: '>=8'} - import-without-cache@0.2.5: resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} engines: {node: '>=20.19.0'} @@ -4171,9 +4102,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jju@1.4.0: - resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} - jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} @@ -4222,9 +4150,6 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - jsonfile@6.2.1: - resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4381,10 +4306,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - minimatch@10.2.3: - resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} - engines: {node: 18 || 20 || >=22} - minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -4908,10 +4829,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} @@ -4940,10 +4857,6 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} - string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -4995,10 +4908,6 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -5199,10 +5108,6 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -6096,41 +6001,6 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@microsoft/api-extractor-model@7.33.8(@types/node@24.12.0)': - dependencies: - '@microsoft/tsdoc': 0.16.0 - '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.23.1(@types/node@24.12.0) - transitivePeerDependencies: - - '@types/node' - - '@microsoft/api-extractor@7.58.7(@types/node@24.12.0)': - dependencies: - '@microsoft/api-extractor-model': 7.33.8(@types/node@24.12.0) - '@microsoft/tsdoc': 0.16.0 - '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.23.1(@types/node@24.12.0) - '@rushstack/rig-package': 0.7.3 - '@rushstack/terminal': 0.24.0(@types/node@24.12.0) - '@rushstack/ts-command-line': 5.3.9(@types/node@24.12.0) - diff: 8.0.4 - minimatch: 10.2.3 - resolve: 1.22.11 - semver: 7.7.4 - source-map: 0.6.1 - typescript: 5.9.3 - transitivePeerDependencies: - - '@types/node' - - '@microsoft/tsdoc-config@0.18.1': - dependencies: - '@microsoft/tsdoc': 0.16.0 - ajv: 8.18.0 - jju: 1.4.0 - resolve: 1.22.11 - - '@microsoft/tsdoc@0.16.0': {} - '@modelcontextprotocol/conformance@0.2.0-alpha.3(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) @@ -6464,45 +6334,6 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/node-core-library@5.23.1(@types/node@24.12.0)': - dependencies: - ajv: 8.18.0 - ajv-draft-04: 1.0.0(ajv@8.18.0) - ajv-formats: 3.0.1(ajv@8.18.0) - fs-extra: 11.3.5 - import-lazy: 4.0.0 - jju: 1.4.0 - resolve: 1.22.11 - semver: 7.7.4 - optionalDependencies: - '@types/node': 24.12.0 - - '@rushstack/problem-matcher@0.2.1(@types/node@24.12.0)': - optionalDependencies: - '@types/node': 24.12.0 - - '@rushstack/rig-package@0.7.3': - dependencies: - jju: 1.4.0 - resolve: 1.22.11 - - '@rushstack/terminal@0.24.0(@types/node@24.12.0)': - dependencies: - '@rushstack/node-core-library': 5.23.1(@types/node@24.12.0) - '@rushstack/problem-matcher': 0.2.1(@types/node@24.12.0) - supports-color: 8.1.1 - optionalDependencies: - '@types/node': 24.12.0 - - '@rushstack/ts-command-line@5.3.9(@types/node@24.12.0)': - dependencies: - '@rushstack/terminal': 0.24.0(@types/node@24.12.0) - '@types/argparse': 1.0.38 - argparse: 1.0.10 - string-argv: 0.3.2 - transitivePeerDependencies: - - '@types/node' - '@shikijs/engine-oniguruma@3.23.0': dependencies: '@shikijs/types': 3.23.0 @@ -6540,8 +6371,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/argparse@1.0.38': {} - '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 24.12.0 @@ -6888,10 +6717,6 @@ snapshots: acorn@8.16.0: {} - ajv-draft-04@1.0.0(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 - ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -7293,8 +7118,6 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 - diff@8.0.4: {} - dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -7880,12 +7703,6 @@ snapshots: fs-constants@1.0.0: {} - fs-extra@11.3.5: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -8036,8 +7853,6 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-lazy@4.0.0: {} - import-without-cache@0.2.5: {} imurmurhash@0.1.4: {} @@ -8200,8 +8015,6 @@ snapshots: isexe@2.0.0: {} - jju@1.4.0: {} - jose@6.2.2: {} js-yaml@3.14.2: @@ -8244,12 +8057,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonfile@6.2.1: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -8388,10 +8195,6 @@ snapshots: - bufferutil - utf-8-validate - minimatch@10.2.3: - dependencies: - brace-expansion: 5.0.5 - minimatch@10.2.4: dependencies: brace-expansion: 5.0.5 @@ -9004,8 +8807,6 @@ snapshots: source-map-js@1.2.1: {} - source-map@0.6.1: {} - spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -9028,8 +8829,6 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - string-argv@0.3.2: {} - string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -9097,10 +8896,6 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} tapable@2.3.2: {} @@ -9312,8 +9107,6 @@ snapshots: universalify@0.1.2: {} - universalify@2.0.1: {} - unpipe@1.0.0: {} unrs-resolver@1.11.1: diff --git a/scripts/generate-api-reports.ts b/scripts/generate-api-reports.ts deleted file mode 100644 index ca45a46458..0000000000 --- a/scripts/generate-api-reports.ts +++ /dev/null @@ -1,683 +0,0 @@ -/** - * Generates (or checks) the committed API reports for every public package. - * - * Each export-map entry of each publishable package gets an API Extractor - * report (`packages//etc/.api.md`) describing its complete public - * type surface. The committed reports are the baseline: `pnpm api-report:check` - * fails when the built surface differs from the committed report, which makes - * every public-surface change a deliberate act (regenerate with - * `pnpm api-report`, commit the diff, and have it reviewed). - * - * The PACKAGES manifest below is cross-checked against the actual package - * export maps before anything runs: a public package or `types` target that - * is neither listed nor explicitly exempted fails the script, so the gate - * cannot silently lose coverage when the export maps grow. - * - * Mechanics: the packages build with tsdown, which emits rolled-up `.d.mts` - * declaration bundles per entry point. Each entry's declarations are mirrored - * into a scratch folder (`.api-extractor-tmp/`, gitignored) so the run can - * host ambient fixups and a scoped tsconfig without polluting dist/, and API - * Extractor runs against the mirrored `.d.mts` entry directly. - * - * Notes on coverage: - * - `@modelcontextprotocol/core` is private and bundled into the client and - * server dists, so its surface is reported THROUGH those packages' reports - * rather than separately. - * - The `./_shims` entries are runtime-conditional; every distinct `types` - * target gets its own report (node, workerd, and — for the client — browser). - * - The codemod package is exempt entirely: it is migration tooling — its - * `mcp-codemod` bin is the contract (covered by the codemod CLI tests and - * the export-map topology pins), and its library surface carries no - * stability expectations for external consumers. - * - * A failing check does not mean a change is wrong — it means it is - * surface-visible. To accept it: run `pnpm api-report`, review the report - * diff like source, and commit it together with the change (plus a - * changeset if consumer-facing). - * - * Usage: - * pnpm api-report # build + regenerate the committed reports - * pnpm api-report:check # build + fail on any difference (CI) - */ -import { Extractor, ExtractorConfig, ExtractorLogLevel } from '@microsoft/api-extractor'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const checkMode = process.argv.includes('--check'); - -interface EntrySpec { - /** Entry declaration rollup, relative to the package's dist/ folder. */ - dist: string; - /** Report file name (api-extractor appends `.api.md`). */ - report: string; -} - -interface PackageSpec { - /** Package folder relative to the repo root. */ - dir: string; - entries: EntrySpec[]; -} - -const PACKAGES: PackageSpec[] = [ - { - dir: 'packages/client', - entries: [ - { dist: 'index.d.mts', report: 'client' }, - { dist: 'stdio.d.mts', report: 'client.stdio' }, - { dist: 'validators/ajv.d.mts', report: 'client.validators-ajv' }, - { dist: 'validators/cfWorker.d.mts', report: 'client.validators-cf-worker' }, - { dist: 'shimsNode.d.mts', report: 'client.shims' }, - { dist: 'shimsWorkerd.d.mts', report: 'client.shims-workerd' }, - { dist: 'shimsBrowser.d.mts', report: 'client.shims-browser' } - ] - }, - { - dir: 'packages/server', - entries: [ - { dist: 'index.d.mts', report: 'server' }, - { dist: 'stdio.d.mts', report: 'server.stdio' }, - { dist: 'validators/ajv.d.mts', report: 'server.validators-ajv' }, - { dist: 'validators/cfWorker.d.mts', report: 'server.validators-cf-worker' }, - { dist: 'shimsNode.d.mts', report: 'server.shims' }, - { dist: 'shimsWorkerd.d.mts', report: 'server.shims-workerd' } - ] - }, - { - dir: 'packages/server-legacy', - entries: [ - { dist: 'index.d.mts', report: 'server-legacy' }, - { dist: 'sse/index.d.mts', report: 'server-legacy.sse' }, - { dist: 'auth/index.d.mts', report: 'server-legacy.auth' } - ] - }, - { dir: 'packages/middleware/express', entries: [{ dist: 'index.d.mts', report: 'express' }] }, - { dir: 'packages/middleware/fastify', entries: [{ dist: 'index.d.mts', report: 'fastify' }] }, - { dir: 'packages/middleware/hono', entries: [{ dist: 'index.d.mts', report: 'hono' }] }, - { dir: 'packages/middleware/node', entries: [{ dist: 'index.d.mts', report: 'node' }] } -]; - -/** Public packages deliberately excluded from API reports, with the reason on record. */ -const EXEMPT_PACKAGES = new Map([ - ['packages/codemod', 'migration tooling: the mcp-codemod bin is the contract (codemod CLI tests + export-map topology pins)'] -]); - -/** Collect every `types` target reachable through an export-map value (handles nested conditions). */ -function collectTypesTargets(node: unknown, out: Set): void { - if (typeof node !== 'object' || node === null) { - return; - } - for (const [key, value] of Object.entries(node)) { - if (key === 'types' && typeof value === 'string') { - out.add(value); - } else { - collectTypesTargets(value, out); - } - } -} - -/** Find every package manifest under packages/ (top-most manifest wins; scratch/build dirs skipped). */ -function discoverPackageDirs(dir: string, found: string[] = []): string[] { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) { - continue; - } - const sub = path.join(dir, entry.name); - if (fs.existsSync(path.join(sub, 'package.json'))) { - found.push(sub); - } else { - discoverPackageDirs(sub, found); - } - } - return found; -} - -/** - * Fails when PACKAGES drifts from reality: a public package missing from the - * manifest (and not exempted), an export-map `types` target with no report - * entry, a report entry no export targets, or a private/exempt package still - * carrying committed reports. - */ -function verifyManifestCoverage(): void { - const problems: string[] = []; - const byDir = new Map(PACKAGES.map(pkg => [pkg.dir, pkg])); - - for (const abs of discoverPackageDirs(path.join(repoRoot, 'packages'))) { - const rel = path.relative(repoRoot, abs).split(path.sep).join('/'); - const manifest = JSON.parse(fs.readFileSync(path.join(abs, 'package.json'), 'utf8')) as { - private?: boolean; - exports?: Record; - }; - const pkg = byDir.get(rel); - - if (manifest.private === true || EXEMPT_PACKAGES.has(rel)) { - if (pkg) { - problems.push(`${rel} is ${manifest.private ? 'private' : 'exempt'} but listed in PACKAGES`); - } - const etcDir = path.join(abs, 'etc'); - if (fs.existsSync(etcDir)) { - for (const file of fs.readdirSync(etcDir)) { - if (file.endsWith('.api.md')) { - problems.push(`${rel}/etc/${file} exists but the package is not reported — remove it`); - } - } - } - continue; - } - if (!pkg) { - problems.push( - `${rel} is a public package with no PACKAGES entry — add its export entries (or an explicit exemption with a reason)` - ); - continue; - } - - const targets = new Set(); - collectTypesTargets(manifest.exports ?? {}, targets); - const targetDists = new Set([...targets].map(target => target.replace(/^\.\/dist\//, ''))); - const listedDists = new Set(pkg.entries.map(entry => entry.dist)); - for (const dist of targetDists) { - if (!listedDists.has(dist)) { - problems.push(`${rel}: exports types target ./dist/${dist} has no report entry in PACKAGES`); - } - } - for (const dist of listedDists) { - if (!targetDists.has(dist)) { - problems.push(`${rel}: report entry ${dist} matches no exports types target`); - } - } - } - - for (const pkg of PACKAGES) { - if (!fs.existsSync(path.join(repoRoot, pkg.dir, 'package.json'))) { - problems.push(`${pkg.dir} is listed in PACKAGES but has no package.json`); - } - } - - if (problems.length > 0) { - throw new Error(`PACKAGES is out of sync with the package export maps:\n ${problems.join('\n ')}`); - } -} - -/** Recursively copy every .d.mts file under distDir into mirrorDir. */ -function mirrorDeclarations(distDir: string, mirrorDir: string): number { - let copied = 0; - for (const entry of fs.readdirSync(distDir, { withFileTypes: true })) { - const from = path.join(distDir, entry.name); - if (entry.isDirectory()) { - copied += mirrorDeclarations(from, path.join(mirrorDir, entry.name)); - } else if (entry.name.endsWith('.d.mts')) { - fs.mkdirSync(mirrorDir, { recursive: true }); - fs.copyFileSync(from, path.join(mirrorDir, entry.name)); - copied += 1; - } - } - return copied; -} - -type EntryStatus = 'ok' | 'updated' | 'changed' | 'missing'; - -function runEntry(pkg: PackageSpec, entry: EntrySpec): EntryStatus { - const pkgDir = path.join(repoRoot, pkg.dir); - const distDir = path.join(pkgDir, 'dist'); - const distEntry = path.join(distDir, entry.dist); - if (!fs.existsSync(distEntry)) { - throw new Error(`Missing build output ${distEntry} — run the package builds first (pnpm api-report does this).`); - } - - const tmpDir = path.join(pkgDir, '.api-extractor-tmp', entry.report); - fs.rmSync(tmpDir, { recursive: true, force: true }); - mirrorDeclarations(distDir, tmpDir); - fs.mkdirSync(path.join(pkgDir, 'etc'), { recursive: true }); - - // The tsdown-bundled ajv declarations inline ajv's types, which reference - // uri-js's URIComponent without importing it. The dangling reference is - // harmless for consumers (skipLibCheck) but crashes API Extractor's symbol - // walker, so resolve it with an ambient declaration in the scratch mirror. - // (Injected for every entry: it is ambient-only and never exported, so it - // cannot appear in a report.) - fs.writeFileSync(path.join(tmpDir, '_ambient-fixups.d.ts'), 'type URIComponent = unknown;\n'); - - const packageJsonFullPath = path.join(pkgDir, 'package.json'); - const extractorConfig = ExtractorConfig.prepare({ - configObject: { - projectFolder: tmpDir, - // API Extractor consumes the mirrored .d.mts entry directly - // (supported since 7.36.2); the rollups' relative .mjs chunk - // imports resolve to the sibling mirrored .d.mts files. - mainEntryPointFilePath: path.join(tmpDir, entry.dist), - newlineKind: 'lf', - compiler: { - overrideTsconfig: { - compilerOptions: { - module: 'esnext', - moduleResolution: 'bundler', - target: 'es2022', - lib: ['es2023', 'dom'], - types: ['node'], - skipLibCheck: true - }, - include: ['**/*.d.ts', '**/*.d.mts'] - } - }, - apiReport: { - enabled: true, - reportFileName: entry.report, - // The report is produced into the scratch folder and compared / - // committed by this script (after normalization), never written - // to etc/ by API Extractor directly. - reportFolder: path.join(tmpDir, 'report'), - reportTempFolder: path.join(tmpDir, 'report'), - includeForgottenExports: true - }, - docModel: { enabled: false }, - dtsRollup: { enabled: false }, - tsdocMetadata: { enabled: false }, - messages: { - extractorMessageReporting: { - 'ae-missing-release-tag': { logLevel: ExtractorLogLevel.None }, - // Not added to the report file: the warning text embeds - // declaration-bundle chunk file names and line numbers, - // which vary with tsdown's chunking and would make the - // committed reports nondeterministic. The forgotten symbols - // themselves still appear in the report body. - 'ae-forgotten-export': { logLevel: ExtractorLogLevel.None, addToApiReportFile: false }, - 'ae-unresolved-link': { logLevel: ExtractorLogLevel.None } - }, - tsdocMessageReporting: { - default: { logLevel: ExtractorLogLevel.None } - } - } - }, - configObjectFullPath: path.join(tmpDir, 'api-extractor.json'), - packageJsonFullPath - }); - - const result = Extractor.invoke(extractorConfig, { - localBuild: true, - showVerboseMessages: false - }); - if (!result.succeeded) { - throw new Error('API Extractor reported errors'); - } - - const raw = fs.readFileSync(path.join(tmpDir, 'report', `${entry.report}.api.md`), 'utf8'); - const produced = normalizeReport(raw, `${pkg.dir} → ${entry.report}.api.md`); - const committedPath = path.join(pkgDir, 'etc', `${entry.report}.api.md`); - const committed = fs.existsSync(committedPath) ? fs.readFileSync(committedPath, 'utf8') : undefined; - - if (checkMode) { - if (committed === undefined) { - return 'missing'; - } - return produced === committed ? 'ok' : 'changed'; - } - if (produced !== committed) { - fs.writeFileSync(committedPath, produced); - return 'updated'; - } - return 'ok'; -} - -/* - * Report normalization - * -------------------- - * tsdown's d.mts rollups rename identifiers that collide when hoisted into the - * bundle's shared scope with `$N` suffixes (e.g. `Ajv$1`), and both whether a - * collision happens and which declaration keeps the bare name depend on how - * the bundle was chunked — which shifts whenever the module graph changes. - * Committing raw reports would therefore churn on surface-neutral changes. - * - * Blanket-stripping the suffixes is not sound either: distinct types sharing a - * source name would fold into one (masking reference-identity changes), and a - * text-global regex would also rewrite string-literal types that happen to - * contain `$`. - * - * So normalization works structurally on the report's declaration blocks: - * 1. Group declarations whose names differ only by a `$N` suffix. - * 2. Compute each group member's layout-independent identity: its blocks with - * every `$N` suffix erased, string-literal content untouched. - * 3. Members with identical identities are the same type duplicated across - * chunks: they all take the bare name and fold into one block. - * 4. Members with distinct identities are genuinely different types sharing a - * source name: they get deterministic, identity-ranked names (`Foo`, - * `Foo$2`, …) so they stay distinguishable regardless of chunk layout. - * 5. Renames apply in a single token pass that skips string and template - * literal content, blocks are deduped and re-sorted by name, and any - * rollup suffix that survives normalization fails the run loudly. - */ - -/** - * Apply `replace` to every match of `re` (global) in the code portions of - * `text`, leaving the content of '…'/"…" strings and `…` template literals - * untouched (template interpolations `${…}` are treated as code) and copying - * `// …` comment lines verbatim. - */ -function replaceOutsideStrings(text: string, re: RegExp, replace: (match: string) => string): string { - let out = ''; - let i = 0; - const n = text.length; - while (i < n) { - const ch = text[i]; - if (ch === "'" || ch === '"') { - let j = i + 1; - while (j < n && text[j] !== ch) { - j += text[j] === '\\' ? 2 : 1; - } - out += text.slice(i, Math.min(j + 1, n)); - i = j + 1; - } else if (ch === '`') { - out += '`'; - let j = i + 1; - while (j < n && text[j] !== '`') { - if (text[j] === '\\') { - out += text.slice(j, j + 2); - j += 2; - } else if (text[j] === '$' && text[j + 1] === '{') { - let depth = 1; - let k = j + 2; - while (k < n && depth > 0) { - if (text[k] === '{') depth += 1; - else if (text[k] === '}') depth -= 1; - k += 1; - } - out += '${' + replaceOutsideStrings(text.slice(j + 2, k - 1), re, replace) + '}'; - j = k; - } else { - out += text[j]; - j += 1; - } - } - if (j < n) { - out += '`'; - } - i = j + 1; - } else if (ch === '/' && text[i + 1] === '/') { - const lineEnd = text.indexOf('\n', i); - const end = lineEnd === -1 ? n : lineEnd; - out += text.slice(i, end); - i = end; - } else { - let j = i; - while (j < n && text[j] !== "'" && text[j] !== '"' && text[j] !== '`' && !(text[j] === '/' && text[j + 1] === '/')) { - j += 1; - } - out += text.slice(i, j).replace(re, replace); - i = j; - } - } - return out; -} - -/** Split fence content into blocks: blank lines at bracket depth zero are boundaries. */ -function splitBlocks(content: string): string[] { - const blocks: string[] = []; - let current: string[] = []; - let depth = 0; - for (const line of content.split('\n')) { - if (line.trim() === '' && depth === 0) { - if (current.length > 0) { - blocks.push(current.join('\n')); - current = []; - } - continue; - } - current.push(line); - depth = Math.max(0, depth + bracketDelta(line)); - } - if (current.length > 0) { - blocks.push(current.join('\n')); - } - return blocks; -} - -function bracketDelta(line: string): number { - let delta = 0; - let quote: string | null = null; - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - if (quote !== null) { - if (ch === '\\') { - i += 1; - } else if (ch === quote) { - quote = null; - } - } else if (ch === "'" || ch === '"' || ch === '`') { - quote = ch; - } else if (ch === '/' && line[i + 1] === '/') { - break; - } else if (ch === '{' || ch === '(' || ch === '[') { - delta += 1; - } else if (ch === '}' || ch === ')' || ch === ']') { - delta -= 1; - } - } - return delta; -} - -const DECLARATION_RE = - /^(?:export\s+)?(?:declare\s+)?(?:abstract\s+)?(?:class|interface|enum|namespace|function|type|const|let|var)\s+([A-Za-z_$][\w$]*)/; - -/** The name a declaration block declares, or null for imports/footers/unrecognized blocks. */ -function declaredName(block: string): string | null { - for (const line of block.split('\n')) { - if (line.startsWith('//')) { - continue; - } - const match = line.match(DECLARATION_RE); - return match ? match[1] : null; - } - return null; -} - -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -const SUFFIX_TOKEN_RE = /\b[A-Za-z_][A-Za-z0-9_]*(?:\$\d+)+\b/g; - -/** Erase every rollup `$N` suffix outside string literals (identity computation only). */ -function eraseAllSuffixes(text: string): string { - return replaceOutsideStrings(text, SUFFIX_TOKEN_RE, token => token.replace(/(?:\$\d+)+$/, '')); -} - -/** - * Rename rollup-suffixed tokens that are local to one block — generic type - * parameters renamed because they collide with a hoisted top-level name (e.g. - * `StrictNullChecksWrapper` next to a top-level - * `Name` class). Such names are alpha-renamable within their block: each gets - * the bare base name unless that would capture an existing token in the block, - * in which case it gets the first free `base$K` by first-appearance order — - * deterministic regardless of which `$N` the bundler happened to pick. - */ -function renameLocalSuffixes(block: string, knownAliases: Set): string { - const order: string[] = []; - const found = new Set(); - replaceOutsideStrings(block, SUFFIX_TOKEN_RE, token => { - if (!knownAliases.has(token) && !found.has(token)) { - found.add(token); - order.push(token); - } - return token; - }); - if (order.length === 0) { - return block; - } - const placeholderOf = new Map(order.map((token, i) => [token, `\u0000${i}\u0000`])); - let text = replaceOutsideStrings(block, SUFFIX_TOKEN_RE, token => placeholderOf.get(token) ?? token); - const present = new Set(text.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []); - for (const raw of order) { - const base = raw.match(/^(.*[A-Za-z0-9_])(?:\$\d+)+$/)![1]; - let alias = base; - for (let k = 2; present.has(alias); k++) { - alias = `${base}$${k}`; - } - present.add(alias); - knownAliases.add(alias); - text = text.split(placeholderOf.get(raw)!).join(alias); - } - return text; -} - -function normalizeReport(text: string, label: string): string { - const lf = text.replace(/\r\n/g, '\n'); - const fenceOpen = lf.indexOf('```ts\n'); - const fenceClose = lf.lastIndexOf('\n```'); - if (fenceOpen === -1 || fenceClose === -1 || fenceClose <= fenceOpen) { - throw new Error(`${label}: unexpected report layout (no \`\`\`ts fence)`); - } - const prolog = lf.slice(0, fenceOpen + '```ts\n'.length); - const epilog = lf.slice(fenceClose); - const body = lf.slice(fenceOpen + '```ts\n'.length, fenceClose); - - // 1. Index declarations by name (a name can own several blocks: overloads). - const blocksByName = new Map(); - for (const block of splitBlocks(body)) { - const name = declaredName(block); - if (name !== null) { - const list = blocksByName.get(name) ?? []; - list.push(block); - blocksByName.set(name, list); - } - } - - // 2. Group `$N`-suffixed declarations with their base name and assign - // aliases by layout-independent identity. - const groups = new Map(); - for (const name of blocksByName.keys()) { - const base = name.match(/^(.*[A-Za-z0-9_])(?:\$\d+)+$/)?.[1]; - if (base !== undefined) { - const members = groups.get(base) ?? []; - members.push(name); - groups.set(base, members); - } - } - const rename = new Map(); - const assignedAliases = new Set(); - for (const [base, suffixed] of groups) { - const members = blocksByName.has(base) ? [base, ...suffixed] : suffixed; - const identities = new Map(members.map(raw => [raw, blocksByName.get(raw)!.map(eraseAllSuffixes).sort().join('\n\n')])); - // Exported declarations outrank internal ones so the public type keeps - // the bare name (`export class Ajv extends Ajv$2`, not the reverse). - const rankKey = (identity: string) => `${/^export\s/m.test(identity) ? 0 : 1}${identity}`; - const distinct = [...new Set(identities.values())].sort((a, b) => (rankKey(a) < rankKey(b) ? -1 : 1)); - for (const raw of members) { - const rank = distinct.indexOf(identities.get(raw)!); - const alias = rank === 0 ? base : `${base}$${rank + 1}`; - rename.set(raw, alias); - assignedAliases.add(alias); - } - } - - // 3. Apply all renames in one token pass (longest-first alternation, so a - // swap like Foo↔Foo$2 cannot cascade), skipping string literals. - let renamed = body; - if (rename.size > 0) { - const alternation = [...rename.keys()] - .sort((a, b) => b.length - a.length) - .map(escapeRegExp) - .join('|'); - const tokenRe = new RegExp(`\\b(?:${alternation})\\b`, 'g'); - renamed = replaceOutsideStrings(body, tokenRe, raw => rename.get(raw) ?? raw); - } - - // 4. Re-assemble: imports first (original order), declarations deduped and - // sorted by (name, body), footer comments last. - const imports: string[] = []; - const footers: string[] = []; - const decls: { name: string; text: string }[] = []; - const seen = new Set(); - for (const rawBlock of splitBlocks(renamed)) { - const block = renameLocalSuffixes(rawBlock, assignedAliases); - if (/^import[\s{]/.test(block)) { - if (!seen.has(block)) { - seen.add(block); - imports.push(block); - } - } else if (block.startsWith('// (No @packageDocumentation')) { - footers.push(block); - } else if (!seen.has(block)) { - // Duplicate blocks are the same type emitted into several chunks; - // after renaming they are byte-identical and fold into one. - seen.add(block); - decls.push({ name: declaredName(block) ?? '', text: block }); - } - } - decls.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.text < b.text ? -1 : a.text > b.text ? 1 : 0)); - - const normalizedBody = [...imports, ...decls.map(d => d.text), ...footers].join('\n\n'); - - // 5. Any surviving rollup suffix that is not one of our deterministic - // aliases means normalization missed a case — fail rather than commit - // a layout-dependent baseline. The scan covers the code body only: the - // markdown prolog/epilog contain the ```ts fence, whose backticks would - // desynchronize the scanner's template-literal tracking. - const leftovers = new Set(); - replaceOutsideStrings(normalizedBody, SUFFIX_TOKEN_RE, token => { - leftovers.add(token); - return token; - }); - for (const token of leftovers) { - if (!assignedAliases.has(token)) { - throw new Error(`${label}: leftover rollup suffix '${token}' — normalization missed a collision case`); - } - } - - return prolog + '\n' + normalizedBody + epilog; -} - -verifyManifestCoverage(); - -let failed = false; -let mismatches = 0; - -for (const pkg of PACKAGES) { - const expectedReports = new Set(pkg.entries.map(entry => `${entry.report}.api.md`)); - for (const entry of pkg.entries) { - const label = `${pkg.dir} → ${entry.report}.api.md`; - try { - const status = runEntry(pkg, entry); - if (status === 'changed' || status === 'missing') { - failed = true; - mismatches += 1; - console.error(`${status === 'missing' ? 'MISSING ' : 'CHANGED '} ${label}`); - } else { - console.log(`${status === 'updated' ? 'UPDATED ' : 'ok '} ${label}`); - } - } catch (error) { - failed = true; - console.error(`ERROR ${label}: ${error instanceof Error ? error.message : String(error)}`); - } - } - - // Committed reports nothing produces anymore are stale baselines: fail the - // check on them, and clean them up on regeneration. - const etcDir = path.join(repoRoot, pkg.dir, 'etc'); - if (fs.existsSync(etcDir)) { - for (const file of fs.readdirSync(etcDir)) { - if (!file.endsWith('.api.md') || expectedReports.has(file)) { - continue; - } - if (checkMode) { - failed = true; - console.error(`ORPHAN ${pkg.dir}/etc/${file} — no PACKAGES entry produces it; remove it (pnpm api-report does)`); - } else { - fs.rmSync(path.join(etcDir, file)); - console.log(`REMOVED ${pkg.dir}/etc/${file} (orphan)`); - } - } - } - fs.rmSync(path.join(repoRoot, pkg.dir, '.api-extractor-tmp'), { recursive: true, force: true }); -} - -if (failed) { - if (mismatches > 0) { - console.error( - '\nThe built public type surface differs from the committed API report(s) above.\n' + - 'If the change is intentional: run `pnpm api-report`, review the report diff,\n' + - 'commit it together with your change (and a changeset if consumer-facing).\n' + - 'See the header of scripts/generate-api-reports.ts.' - ); - } - process.exit(1); -} From bd799be815ff2d58b4ef5e0aa825ea9add3564c3 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:43:02 +0100 Subject: [PATCH 10/37] feat(core)!: per-era wire codec interface (#2294) --- .changeset/codec-era-gates.md | 7 + .changeset/codec-split-wire-break.md | 15 + docs/migration-SKILL.md | 15 +- docs/migration.md | 44 ++ packages/client/src/client/client.ts | 111 +++-- .../codemod/src/generated/specSchemaMap.ts | 15 - packages/core/src/errors/sdkErrors.ts | 8 + packages/core/src/index.ts | 7 + packages/core/src/shared/protocol.ts | 408 +++++++++++++++--- packages/core/src/types/guards.ts | 6 + packages/core/src/types/schemas.ts | 363 +--------------- packages/core/src/types/specTypeSchema.ts | 24 +- packages/core/src/types/types.ts | 76 +++- packages/core/src/wire/bootstrap.ts | 40 ++ packages/core/src/wire/codec.ts | 206 +++++++++ packages/core/src/wire/rev2025-11-25/codec.ts | 64 +++ .../core/src/wire/rev2025-11-25/registry.ts | 213 +++++++++ .../core/src/wire/rev2025-11-25/schemas.ts | 326 ++++++++++++++ .../core/src/wire/rev2026-07-28/schemas.ts | 65 +++ packages/core/test/corpus/specCorpus.test.ts | 21 +- .../core/test/shared/customMethods.test.ts | 39 +- packages/core/test/shared/protocol.test.ts | 143 ++++++ .../test/shared/rawResultTypeFirst.test.ts | 40 +- .../test/shared/typedMapAlignment.test.ts | 33 +- .../core/test/spec.types.2025-11-25.test.ts | 25 +- packages/core/test/types.test.ts | 63 ++- .../core/test/types/errorSurfacePins.test.ts | 1 + packages/core/test/types/registryPins.test.ts | 198 +++++++++ .../test/types/schemaBoundaryPins.test.ts | 44 +- .../core/test/types/specTypeSchema.test.ts | 34 +- .../core/test/types/wireOnlyHiding.test.ts | 37 +- packages/server/src/server/server.ts | 59 ++- packages/server/test/server/server.test.ts | 65 ++- test/integration/test/client/client.test.ts | 42 ++ 34 files changed, 2246 insertions(+), 611 deletions(-) create mode 100644 .changeset/codec-era-gates.md create mode 100644 .changeset/codec-split-wire-break.md create mode 100644 packages/core/src/wire/bootstrap.ts create mode 100644 packages/core/src/wire/codec.ts create mode 100644 packages/core/src/wire/rev2025-11-25/codec.ts create mode 100644 packages/core/src/wire/rev2025-11-25/registry.ts create mode 100644 packages/core/src/wire/rev2025-11-25/schemas.ts create mode 100644 packages/core/src/wire/rev2026-07-28/schemas.ts create mode 100644 packages/core/test/types/registryPins.test.ts diff --git a/.changeset/codec-era-gates.md b/.changeset/codec-era-gates.md new file mode 100644 index 0000000000..30855b7f87 --- /dev/null +++ b/.changeset/codec-era-gates.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Add `SdkErrorCode.MethodNotSupportedByProtocolVersion`: a typed local error raised before anything reaches the transport when a spec method is sent toward a peer whose negotiated protocol version's wire era does not define it (for example `tasks/get` toward a 2026-07-28 peer). The protocol layer now resolves a per-era wire codec from the connection's negotiated protocol version (instance state on `Client`/`Server`, with the legacy era as the pre-negotiation default) and resolves per-method schemas at dispatch time instead of registration time; an edge classification on an inbound message is validated against that instance era, and a mismatch is rejected as an entry/routing error. Behavior on existing (2025-era) connections is unchanged. diff --git a/.changeset/codec-split-wire-break.md b/.changeset/codec-split-wire-break.md new file mode 100644 index 0000000000..2a20452ab6 --- /dev/null +++ b/.changeset/codec-split-wire-break.md @@ -0,0 +1,15 @@ +--- +'@modelcontextprotocol/core': major +'@modelcontextprotocol/client': major +'@modelcontextprotocol/server': major +--- + +Split the wire layer into per-era codecs and make protocol-revision deletions physical. Deliberate wire/schema behavior changes (see docs/migration.md "Per-era wire codecs"): + +- `resultType` is no longer modeled by any neutral wire schema: `EmptyResultSchema` (strict) now rejects `{resultType}` bodies; on 2025-era connections a foreign `resultType` is stripped before validation instead of rejected; the member exists only inside the 2026-era codec, which requires it. +- `CallToolResult.content` / `ToolResultContent.content` are required at the wire boundary (`content.default([])` removed): handler results without `content` are rejected with `-32602` instead of silently defaulted, and content-less wire results fail the client parse loudly. +- Custom (3-arg) handlers now receive `_meta` minus the reserved envelope keys instead of having it deleted before params validation. +- `specTypeSchemas` re-scoped to the neutral model: result validators no longer accept `resultType`; task message-type validators and `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed). +- Role aggregate types/schemas (`ClientRequest`, `ServerResult`, …) no longer carry task vocabulary; the deprecated `Task*` types remain importable unchanged. +- Era-mismatched spec methods fail physically: inbound era-deleted methods get `-32601` even with a handler registered; outbound sends throw `SdkErrorCode.MethodNotSupportedByProtocolVersion` locally. +- Value guards (`isCallToolResult`, …) are documented as neutral-shape consumer checks, not wire validators. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 312229a8c5..c906b0bc7d 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -515,7 +515,20 @@ Task methods are excluded from the typed method maps: `RequestMethod`/`RequestTy | `Result['resultType']` type reference | remove; the member is no longer declared | | return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | -Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. A response carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`). Collision note for 2025-era peers: 2025-11-25 reserves the `io.modelcontextprotocol/` `_meta` prefix but NOT the bare names `inputResponses`/`requestState`, so a 2025 peer's custom-method request using those names as ordinary params has them lifted out of `request.params` (recoverable via ctx; everything else passes through untouched). +Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. On a 2026-era exchange a response carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`), while on a 2025-era connection a foreign `resultType` is stripped before validation; the serving wire era is the instance's negotiated protocol version (connection state), and `MessageExtraInfo.classification` is only validated against it at dispatch (a mismatch is rejected as an entry/routing error). Collision note for 2025-era peers: 2025-11-25 reserves the `io.modelcontextprotocol/` `_meta` prefix but NOT the bare names `inputResponses`/`requestState`, so a 2025 peer's custom-method request using those names as ordinary params has them lifted out of `request.params` (recoverable via ctx; everything else passes through untouched). + +## 12c. Per-era wire codecs (physical deletions + stricter wire schemas) + +The wire layer is split into per-era codecs (2025-era = 2024-10-07 … 2025-11-25; 2026-era = 2026-07-28). Era-mismatched spec methods fail physically: inbound -> `-32601` even with a handler registered; outbound -> `SdkError` code `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` before the transport. + +| Pattern in v2-alpha code | Mechanical fix | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| tool handler returns without `content` | add `content: []` (or real content) — results without it are rejected `-32602`, no longer defaulted | +| parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) | +| strict custom-handler params schema (3-arg `setRequestHandler`/`setNotification…`) | add optional `_meta` to the schema (or strip it) — `_meta` is now passed through minus reserved keys | +| `specTypeSchemas`/`SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (types remain importable) | +| `ClientRequest`/`ServerResult`/… aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets | +| relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse | ## 13. Behavioral Changes diff --git a/docs/migration.md b/docs/migration.md index afef43025b..764203ec2b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -919,6 +919,7 @@ The protocol layer enforces the same boundary at runtime: - **Envelope lift.** On inbound requests and notifications, the reserved `io.modelcontextprotocol/*` envelope keys are lifted out of `params._meta` before handlers run, so handler params are byte-equal to the 2025-era shape under 2026-era traffic. For requests the envelope is readable at `ctx.mcpReq.envelope` (typed `Partial` — only the keys the request actually carried are present); for notifications there is no per-message context, so lifted envelope keys are dropped, not surfaced. On requests only, the multi-round-trip retry fields are likewise lifted out of top-level params and surfaced verbatim at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. - **What this means for 2025-era peers.** The `_meta` side of the lift is invisible to conforming 2025-era traffic: the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too, so a conforming 2025 peer never puts application data under those keys. The retry-field lift is the one collision to know about: 2025-11-25 does not reserve the bare names `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that happens to use them as ordinary top-level params will have them lifted out of the handler's view (still readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`, just no longer in `request.params`). Spec-method requests are unaffected (no 2025 spec method defines params with those names), as are all notifications. - **Raw-first result discrimination.** The client funnel inspects a response's raw `resultType` before schema validation: `'complete'` is consumed (stripped) and the result parses as the public shape; any other kind (e.g. `input_required`) rejects with a typed local error — `SdkError` with the new code `SdkErrorCode.UnsupportedResultType` and the kind in `error.data.resultType` — instead of being masked into a hollow success by tolerant result schemas. Full multi-round-trip support will replace that error arm. +- **`MessageExtraInfo.classification`** is an optional carrier (`{ era, revision?, envelope? }`) for transports that classify inbound messages at the edge. The wire era itself is connection state (the negotiated protocol version held by the `Client`/`Server` instance); dispatch validates a classified message against that era and treats a mismatch as an entry/routing error (see the next section). **Before (v2 alpha):** @@ -938,6 +939,49 @@ const result = await client.callTool({ name: 'echo', arguments: {} }); console.log(result.content); ``` +### Per-era wire codecs: physical deletions and stricter wire schemas + +The wire layer is now split into per-revision codecs inside the (private, bundled) core: one codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The codec is selected by the negotiated protocol version, which is connection state on the `Client`/`Server` instance: the client stores it when its initialize handshake completes, the server stores it when it answers `initialize`, and instances with no negotiated version default to the 2025 era (with the pre-negotiation lifecycle messages routed by method: `initialize`/`notifications/initialized` are 2025-era vocabulary, `server/discover` is 2026-era vocabulary). An edge classification (`MessageExtraInfo.classification`) no longer switches the era per message — it is validated against the instance era, and a mismatch is rejected as an entry/routing error (`-32004 Unsupported protocol version` for requests, a drop plus `onerror` for notifications). Methods deleted by a protocol revision are now PHYSICALLY absent from that era's registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a handler is registered, and sending an era-mismatched spec method (for example `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws a typed local error — `SdkError` with the new code `SdkErrorCode.MethodNotSupportedByProtocolVersion` — before anything reaches the transport. + +Alongside the split, the following deliberate wire-behavior changes ship (each is invisible to conforming peers but observable to direct schema consumers and misbehaving peers): + +- **`resultType` is no longer modeled by any neutral wire schema.** The base `ResultSchema` (and every result schema derived from it) no longer declares the optional `resultType` member. Consequences: + - `EmptyResultSchema` (strict) now REJECTS `{resultType: ...}` bodies where it previously accepted them. On the protocol path nothing changes for conforming peers: the 2026-era codec consumes the field, and the 2025-era codec strips a foreign `resultType` before validation (tolerate-and-drop — a 2025-era peer that sends it is misbehaving). + - On a 2025-era connection, a response carrying a non-`'complete'` `resultType` is no longer rejected with `UnsupportedResultType`: the field is foreign vocabulary on that era and is stripped before validation (the result then passes or fails validation on its actual content, loudly). On a 2026-era exchange the discrimination is stricter than before: `resultType` is REQUIRED, an absent value is a spec violation surfaced as a typed error, and `input_required` / unknown kinds reject with `UnsupportedResultType` / `InvalidResult`. +- **`CallToolResult.content` and `ToolResultContent.content` are required at the wire boundary.** The `content.default([])` affordance was removed (it could silently convert unrecognized result shapes into hollow `{content: []}` successes). Tool handlers MUST include `content` in their results (the TypeScript surface always required it — `content: []` is fine); a handler result without it is now rejected with `-32602 Invalid tools/call result` instead of being silently defaulted, and a content-less wire result fails the client-side parse loudly. +- **Custom (3-arg) handlers receive `_meta`.** `setRequestHandler(method, {params}, handler)` / `setNotificationHandler(method, {params}, handler)` used to DELETE `params._meta` before validating with your schema. They now pass it through minus the reserved `io.modelcontextprotocol/*` envelope keys (which the protocol layer lifts out), making custom methods consistent with spec methods. If your params schema is strict (rejects unknown keys), add an optional `_meta` member or strip it yourself. +- **`specTypeSchemas` validate the neutral model.** Result entries no longer accept/declare `resultType`; the validators for the 2025-only task message types (`Task`, `TaskStatus`, `GetTask*`, `ListTasks*`, `CancelTask*`, `CreateTaskResult`, `TaskStatusNotification*`, `TaskCreationParams`) and for `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed accordingly). Per-revision wire validators are planned to return as versioned `zod-schemas/` exports. +- **Role aggregate types no longer carry task vocabulary.** `ClientRequest`, `ClientResult`, `ClientNotification`, `ServerRequest`, `ServerResult`, and `ServerNotification` (and their union schemas) are now the neutral message sets; the task members moved into the internal 2025-era wire module. The individual `Task*` types remain importable (deprecated) exactly as before. +- **Value guards are consumer-side checks, not wire validators.** `isCallToolResult` and friends now validate the neutral shapes; a raw wire object carrying `resultType` still passes them through the loose index signature. Validate raw wire traffic with a transport-level parse, not the guards. + +**Before:** + +```typescript +// A handler omitting content was silently defaulted on the wire: +server.setRequestHandler('tools/call', async () => { + return { structuredContent: { ok: true } } as CallToolResult; // wire: content [] +}); + +// Custom handlers never saw _meta: +protocol.setRequestHandler('acme/op', { params: z.strictObject({ x: z.number() }) }, async params => ({})); +``` + +**After:** + +```typescript +// content is required (as the spec always said): +server.setRequestHandler('tools/call', async () => { + return { content: [], structuredContent: { ok: true } }; +}); + +// Custom handlers receive _meta minus the reserved envelope keys: +protocol.setRequestHandler( + 'acme/op', + { params: z.strictObject({ x: z.number(), _meta: z.record(z.string(), z.unknown()).optional() }) }, + async params => ({}) +); +``` + ## Enhancements ### Automatic JSON Schema validator selection by runtime diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 2bc0acbdda..29710cbea4 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -43,30 +43,20 @@ import type { UnsubscribeRequest } from '@modelcontextprotocol/core'; import { - CallToolResultSchema, - CompleteResultSchema, - CreateMessageRequestSchema, + codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - ElicitRequestSchema, - ElicitResultSchema, - EmptyResultSchema, - GetPromptResultSchema, - InitializeResultSchema, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, mergeCapabilities, + negotiatedProtocolVersionOf, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, - ReadResourceResultSchema, SdkError, - SdkErrorCode + SdkErrorCode, + setNegotiatedProtocolVersion } from '@modelcontextprotocol/core'; /** @@ -225,7 +215,6 @@ export type ClientOptions = ProtocolOptions & { export class Client extends Protocol { private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; - private _negotiatedProtocolVersion?: string; private _capabilities: ClientCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -308,7 +297,19 @@ export class Client extends Protocol { ): (request: JSONRPCRequest, ctx: ClientContext) => Promise { if (method === 'elicitation/create') { return async (request, ctx) => { - const validatedRequest = parseSchema(ElicitRequestSchema, request); + // Era-exact validation: the schemas are resolved from the + // instance era at dispatch time (the era gate guarantees the + // method exists on the serving era before we get here). + const codec = codecForVersion(negotiatedProtocolVersionOf(this)); + const elicitRequestSchema = codec.requestSchema('elicitation/create'); + // The era registry entry IS the plain ElicitResult schema + // (the result map is aligned to the typed map — no widened + // unions), so no narrower surface is needed. + const elicitResultSchema = codec.resultSchema('elicitation/create'); + if (!elicitRequestSchema || !elicitResultSchema) { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'No wire schema for elicitation/create in the resolved era'); + } + const validatedRequest = parseSchema(elicitRequestSchema, request); if (!validatedRequest.success) { // Type guard: if success is false, error is guaranteed to exist const errorMessage = @@ -330,7 +331,7 @@ export class Client extends Protocol { const result = await handler(request, ctx); - const validationResult = parseSchema(ElicitResultSchema, result); + const validationResult = parseSchema(elicitResultSchema, result); if (!validationResult.success) { // Type guard: if success is false, error is guaranteed to exist const errorMessage = @@ -361,7 +362,16 @@ export class Client extends Protocol { if (method === 'sampling/createMessage') { return async (request, ctx) => { - const validatedRequest = parseSchema(CreateMessageRequestSchema, request); + // Era-exact validation via the instance era (see above). + const codec = codecForVersion(negotiatedProtocolVersionOf(this)); + const samplingRequestSchema = codec.requestSchema('sampling/createMessage'); + if (!samplingRequestSchema) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + 'No wire schema for sampling/createMessage in the resolved era' + ); + } + const validatedRequest = parseSchema(samplingRequestSchema, request); if (!validatedRequest.success) { const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); @@ -372,6 +382,11 @@ export class Client extends Protocol { const result = await handler(request, ctx); + // The result schema depends on the REQUEST params (tools vs + // no tools) — something a method-keyed registry entry cannot + // express, so the pair is picked here. The era gate keeps + // this era-correct: sampling/createMessage is only ever + // dispatched on an era whose registry defines it. const hasTools = params.tools || params.toolChoice; const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; const validationResult = parseSchema(resultSchema, result); @@ -429,13 +444,26 @@ export class Client extends Protocol { // Restore the protocol version negotiated during the original initialize handshake // so HTTP transports include the required mcp-protocol-version header, but skip re-init. if (transport.sessionId !== undefined) { - if (this._negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { - transport.setProtocolVersion(this._negotiatedProtocolVersion); + const negotiatedProtocolVersion = negotiatedProtocolVersionOf(this); + if (negotiatedProtocolVersion !== undefined) { + // Resuming keeps the original negotiation: the instance still + // holds the negotiated version (and with it the wire era) — + // only the new transport needs the header pushed again. + transport.setProtocolVersion?.(negotiatedProtocolVersion); } return; } + // Fresh connect: the negotiated protocol version is connection state — + // a value left over from a previous connection must not survive into a + // new handshake. Clearing it puts the instance back in the + // pre-negotiation phase, so the initialize exchange below rides the + // bootstrap method pins (legacy era) instead of a dead session's era. + // Without this, an instance that once negotiated a modern era could + // never re-run a fresh handshake: `initialize` is physically absent + // from the modern registry. (The resume branch above keeps it instead.) + setNegotiatedProtocolVersion(this, undefined); try { - const result = await this._requestWithSchema( + const result = await this.request( { method: 'initialize', params: { @@ -444,7 +472,6 @@ export class Client extends Protocol { clientInfo: this._clientInfo } }, - InitializeResultSchema, options ); @@ -458,7 +485,6 @@ export class Client extends Protocol { this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; - this._negotiatedProtocolVersion = result.protocolVersion; // HTTP transports must set the protocol version in each header after initialization. if (transport.setProtocolVersion) { transport.setProtocolVersion(result.protocolVersion); @@ -470,6 +496,15 @@ export class Client extends Protocol { method: 'notifications/initialized' }); + // Handshake completion: the negotiated version becomes the + // instance's connection state, and with it the wire era for + // everything this connection sends/receives from here on (the + // negotiated version cashes out as the negotiated wire ERA — + // Q1-SD1). Set AFTER the initialized notification: the initialize + // EXCHANGE is the legacy handshake by definition and completes on + // that era. + setNegotiatedProtocolVersion(this, result.protocolVersion); + // Set up list changed handlers now that we know server capabilities if (this._pendingListChangedConfig) { this._setupListChangedHandlers(this._pendingListChangedConfig); @@ -502,7 +537,7 @@ export class Client extends Protocol { * value to the new transport so it continues sending the required `mcp-protocol-version` header. */ getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; + return negotiatedProtocolVersionOf(this); } /** @@ -652,12 +687,12 @@ export class Client extends Protocol { } async ping(options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, options); + return this.request({ method: 'ping' }, options); } /** Requests argument autocompletion suggestions from the server for a prompt or resource. */ async complete(params: CompleteRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'completion/complete', params }, CompleteResultSchema, options); + return this.request({ method: 'completion/complete', params }, options); } /** @@ -668,12 +703,12 @@ export class Client extends Protocol { * Migrate to stderr logging (STDIO servers) or OpenTelemetry. */ async setLoggingLevel(level: LoggingLevel, options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); + return this.request({ method: 'logging/setLevel', params: { level } }, options); } /** Retrieves a prompt by name from the server, passing the given arguments for template substitution. */ async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'prompts/get', params }, GetPromptResultSchema, options); + return this.request({ method: 'prompts/get', params }, options); } /** @@ -704,7 +739,7 @@ export class Client extends Protocol { console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); return { prompts: [] }; } - return this._requestWithSchema({ method: 'prompts/list', params }, ListPromptsResultSchema, options); + return this.request({ method: 'prompts/list', params }, options); } /** @@ -735,7 +770,7 @@ export class Client extends Protocol { console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); return { resources: [] }; } - return this._requestWithSchema({ method: 'resources/list', params }, ListResourcesResultSchema, options); + return this.request({ method: 'resources/list', params }, options); } /** @@ -755,22 +790,22 @@ export class Client extends Protocol { ); return { resourceTemplates: [] }; } - return this._requestWithSchema({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + return this.request({ method: 'resources/templates/list', params }, options); } /** Reads the contents of a resource by URI. */ async readResource(params: ReadResourceRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'resources/read', params }, ReadResourceResultSchema, options); + return this.request({ method: 'resources/read', params }, options); } /** Subscribes to change notifications for a resource. The server must support resource subscriptions. */ async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'resources/subscribe', params }, EmptyResultSchema, options); + return this.request({ method: 'resources/subscribe', params }, options); } /** Unsubscribes from change notifications for a resource. */ async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); + return this.request({ method: 'resources/unsubscribe', params }, options); } /** @@ -811,7 +846,11 @@ export class Client extends Protocol { * ``` */ async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise { - const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); + // The method-keyed request() path validates the era registry's plain + // CallToolResult schema — with the result map aligned to the typed + // map there is no wider union to narrow away (Q1-SD2 holds by + // construction). + const result = await this.request({ method: 'tools/call', params }, options); // Check if the tool has an outputSchema const validator = this.getToolOutputValidator(params.name); @@ -902,7 +941,7 @@ export class Client extends Protocol { console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); return { tools: [] }; } - const result = await this._requestWithSchema({ method: 'tools/list', params }, ListToolsResultSchema, options); + const result = await this.request({ method: 'tools/list', params }, options); // Cache the tools and their output schemas for future validation this.cacheToolMetadata(result.tools); diff --git a/packages/codemod/src/generated/specSchemaMap.ts b/packages/codemod/src/generated/specSchemaMap.ts index 77f3d3dfc8..99d8f84dfb 100644 --- a/packages/codemod/src/generated/specSchemaMap.ts +++ b/packages/codemod/src/generated/specSchemaMap.ts @@ -8,8 +8,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'CallToolRequestParamsSchema', 'CallToolRequestSchema', 'CallToolResultSchema', - 'CancelTaskRequestSchema', - 'CancelTaskResultSchema', 'CancelledNotificationParamsSchema', 'CancelledNotificationSchema', 'ClientCapabilitiesSchema', @@ -25,7 +23,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'CreateMessageRequestSchema', 'CreateMessageResultSchema', 'CreateMessageResultWithToolsSchema', - 'CreateTaskResultSchema', 'CursorSchema', 'DiscoverRequestSchema', 'DiscoverResultSchema', @@ -42,10 +39,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'GetPromptRequestParamsSchema', 'GetPromptRequestSchema', 'GetPromptResultSchema', - 'GetTaskPayloadRequestSchema', - 'GetTaskPayloadResultSchema', - 'GetTaskRequestSchema', - 'GetTaskResultSchema', 'IconSchema', 'IconsSchema', 'ImageContentSchema', @@ -72,8 +65,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'ListResourcesResultSchema', 'ListRootsRequestSchema', 'ListRootsResultSchema', - 'ListTasksRequestSchema', - 'ListTasksResultSchema', 'ListToolsRequestSchema', 'ListToolsResultSchema', 'LoggingLevelSchema', @@ -114,7 +105,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'ReadResourceResultSchema', 'RelatedTaskMetadataSchema', 'RequestIdSchema', - 'RequestMetaEnvelopeSchema', 'RequestMetaSchema', 'RequestSchema', 'ResourceContentsSchema', @@ -144,12 +134,7 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'SubscribeRequestParamsSchema', 'SubscribeRequestSchema', 'TaskAugmentedRequestParamsSchema', - 'TaskCreationParamsSchema', 'TaskMetadataSchema', - 'TaskSchema', - 'TaskStatusNotificationParamsSchema', - 'TaskStatusNotificationSchema', - 'TaskStatusSchema', 'TextContentSchema', 'TextResourceContentsSchema', 'TitledMultiSelectEnumSchemaSchema', diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index 808841807c..1f77d1faca 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -34,6 +34,14 @@ export enum SdkErrorCode { * `input_required`. The kind is carried in `data.resultType`. */ UnsupportedResultType = 'UNSUPPORTED_RESULT_TYPE', + /** + * The spec method being sent does not exist on the negotiated protocol + * version's wire era (e.g. `tasks/get` toward a 2026-07-28 peer, or + * `server/discover` toward a 2025-era peer). Raised locally, before + * anything reaches the transport. The method and era are carried in + * `data.method` / `data.era`. + */ + MethodNotSupportedByProtocolVersion = 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', // Transport errors ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a704267ee3..fc022586f5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,9 +10,16 @@ export * from './shared/transport.js'; export * from './shared/uriTemplate.js'; export * from './types/index.js'; export * from './util/inMemory.js'; +// Wire-codec internals: ONLY the version→codec resolver the sibling packages +// need (era state itself lives on Protocol and is reached through the +// package-internal accessors exported by shared/protocol.ts). Nothing +// per-revision (schemas, registries, codec objects) is ever exported — not +// even on this internal barrel — so per-era vocabulary cannot leak toward the +// public surface. export * from './util/schema.js'; export * from './util/standardSchema.js'; export * from './util/zodCompat.js'; +export { codecForVersion } from './wire/codec.js'; // Validator providers are type-only here — import the runtime classes from the explicit // `@modelcontextprotocol/{core,client,server}/validators/{ajv,cf-worker}` subpaths to customise. diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 5ab67c1546..0c8d99ab43 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -34,9 +34,6 @@ import type { import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, - getNotificationSchema, - getRequestSchema, - getResultSchema, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, @@ -49,6 +46,9 @@ import { } from '../types/index.js'; import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; +import { bootstrapOutboundCodec } from '../wire/bootstrap.js'; +import type { LiftedWireMaterial, WireCodec } from '../wire/codec.js'; +import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod } from '../wire/codec.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** @@ -157,15 +157,6 @@ const RESERVED_ENVELOPE_META_KEYS: readonly string[] = [ */ const RETRY_PARAMS_KEYS = ['inputResponses', 'requestState'] as const; -interface LiftedWireMaterial { - // Partial: the lift surfaces whichever reserved keys the request actually - // carried — a peer on an adjacent revision may legally send a subset, and - // envelope requiredness is enforced per request at dispatch time, not here. - envelope?: Partial; - inputResponses?: Record; - requestState?: string; -} - /** * Lift wire-only material out of an inbound message so handlers see exactly * the 2025-era shape, and surface it for the protocol layer (requests: via @@ -387,6 +378,45 @@ type TimeoutInfo = { onTimeout: () => void; }; +/* + * Package-internal access to Protocol's negotiated-protocol-version state. + * + * The negotiated version is a TS-private field on Protocol (it is connection + * state, not public surface — it never appears in the published declaration + * reports). The role classes (Client/Server), tests, and the modern-era + * server entry still need to read and write it at their lifecycle points, so + * Protocol's static initializer hands these module-scoped closures privileged + * access and the two functions below re-export them on the core INTERNAL + * barrel only. This is the F-2-style package-internal hook — deliberately not + * public API. + */ +let readNegotiatedProtocolVersion: (instance: Protocol) => string | undefined; +let writeNegotiatedProtocolVersion: (instance: Protocol, version: string | undefined) => void; + +/** + * Package-internal read channel for the protocol version a {@linkcode Protocol} + * instance has negotiated (`undefined` before negotiation). Exported on the + * core internal barrel only — never public API. + */ +export function negotiatedProtocolVersionOf(instance: Protocol): string | undefined { + return readNegotiatedProtocolVersion(instance); +} + +/** + * Package-internal write channel for a {@linkcode Protocol} instance's + * negotiated protocol version — the single era set/clear point outside the + * class itself. Called by `Client.connect` (fresh-connect clear + handshake + * completion), `Server._oninitialize`, tests, and the (future) modern-era + * server entry when it marks a factory instance modern at binding time. + * Exported on the core internal barrel only — never public API. + */ +export function setNegotiatedProtocolVersion( + instance: Protocol, + version: string | undefined +): void { + writeNegotiatedProtocolVersion(instance, version); +} + /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. @@ -399,12 +429,37 @@ export abstract class Protocol { private _requestMessageId = 0; private _requestHandlers: Map Promise> = new Map(); private _requestHandlerAbortControllers: Map = new Map(); - private _notificationHandlers: Map Promise> = new Map(); + private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); private _progressHandlers: Map = new Map(); private _timeoutInfo: Map = new Map(); private _pendingDebouncedNotifications = new Set(); + /** + * The protocol version negotiated for the current connection — the single + * source of truth for the wire era this instance speaks (Q1-SD1: the + * negotiated version cashes out as the negotiated wire ERA). + * + * Ordinary connection state, no side tables: + * - `Client.connect` clears it at the start of a fresh connect (the + * handshake itself runs pre-negotiation) and sets it once the handshake + * completes; the resume path keeps the original negotiation. + * - `Server._oninitialize` sets it when answering the legacy handshake; + * modern-era server instances get it set at instance binding through + * the package-internal hook ({@linkcode setNegotiatedProtocolVersion}). + * + * `undefined` = not negotiated yet: outbound lifecycle messages ride the + * bootstrap method pins and everything else defaults to the legacy era. + */ + private _negotiatedProtocolVersion?: string; + + static { + readNegotiatedProtocolVersion = instance => instance._negotiatedProtocolVersion; + writeNegotiatedProtocolVersion = (instance, version) => { + instance._negotiatedProtocolVersion = version; + }; + } + protected _supportedProtocolVersions: string[]; /** @@ -537,7 +592,7 @@ export abstract class Protocol { } else if (isJSONRPCRequest(message)) { this._onrequest(message, extra); } else if (isJSONRPCNotification(message)) { - this._onnotification(message); + this._onnotification(message, extra); } else { this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); } @@ -584,23 +639,57 @@ export abstract class Protocol { this.onerror?.(error); } - private _onnotification(rawNotification: JSONRPCNotification): void { + private _onnotification(rawNotification: JSONRPCNotification, extra?: MessageExtraInfo): void { // Hide wire-only material from notification handlers too — but ONLY // the reserved envelope `_meta` keys (the retry params names are // reserved on requests, not notifications). There is no // per-notification context, so the lifted envelope keys are dropped, // not surfaced; the protocol layer owns them. const { message: notification } = liftWireOnlyMaterial(rawNotification, 'notification'); - const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; + + // Era is instance state: the negotiated protocol version selects the + // codec for everything this connection receives (legacy until + // negotiated). Classification is no longer a per-message era switch — + // it is validated against the instance era below. + const codec = this._negotiatedWireCodec(); + + // Edge→instance handoff check: a classification that disagrees with + // the instance era means the entry routed another era's traffic onto + // this instance. That is a routing error — drop the notification and + // surface it out of band; never serve it on a guessed era. + if (extra?.classification !== undefined) { + const classified = classifiedWireEra(extra.classification); + if (classified !== codec.era) { + this._onerror( + new Error( + `Era mismatch on inbound notification '${notification.method}': classified as ${classified} but this instance serves ${codec.era}` + ) + ); + return; + } + } + + // Era gate — deletions are physical: a spec notification that is not + // in this era's registry is dropped even when a handler is + // registered (notifications get no error response; silent drop is + // the protocol-correct outcome, matching today's unknown-method + // posture). Methods outside the spec universe are consumer-owned + // extension notifications and stay era-blind. + if (isSpecNotificationMethod(notification.method) && !codec.hasNotificationMethod(notification.method)) { + return; + } + + const handler = this._notificationHandlers.get(notification.method); + const fallback = this.fallbackNotificationHandler; // Ignore notifications not being subscribed to. - if (handler === undefined) { + if (handler === undefined && fallback === undefined) { return; } // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() - .then(() => handler(notification)) + .then(() => (handler === undefined ? fallback!(notification) : handler(notification, codec))) .catch(error => this._onerror(new Error(`Uncaught error in notification handler: ${error}`))); } @@ -609,29 +698,99 @@ export abstract class Protocol { // fallback handler and the per-method schema parse) see exactly the // 2025-era shape; the envelope and retry fields surface via ctx. const { message: request, lifted } = liftWireOnlyMaterial(rawRequest, 'request'); - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + + // Era is instance state: the negotiated protocol version selects the + // codec for everything this connection receives (legacy until + // negotiated). Classification (Q2; this layer only CONSUMES + // MessageExtraInfo.classification) is no longer a per-message era + // switch — it is validated against the instance era below. Hand-wired + // legacy transports never classify, so their behavior is untouched. + const codec = this._negotiatedWireCodec(); // Capture the current transport at request time to ensure responses go to the correct client const capturedTransport = this._transport; - const sendNotification = (notification: Notification, options?: NotificationOptions) => - this.notification(notification, { ...options, relatedRequestId: request.id }); - const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => - this._requestWithSchema(r, resultSchema, { ...options, relatedRequestId: request.id }); - - if (handler === undefined) { + const sendErrorResponse = (code: number, message: string, data?: unknown) => { const errorResponse: JSONRPCErrorResponse = { jsonrpc: '2.0', id: request.id, - error: { - code: ProtocolErrorCode.MethodNotFound, - message: 'Method not found' - } + error: { code, message, ...(data !== undefined && { data }) } }; capturedTransport?.send(errorResponse).catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); + }; + + // Edge→instance handoff check: a classification that disagrees with + // the instance era means the entry routed another era's traffic onto + // this instance. That is a routing error: answer with the typed era + // error (−32004 Unsupported protocol version) and surface it out of + // band — never serve the request on a guessed era. + if (extra?.classification !== undefined) { + const classified = classifiedWireEra(extra.classification); + if (classified !== codec.era) { + this._onerror( + new Error( + `Era mismatch on inbound request '${request.method}': classified as ${classified} but this instance serves ${codec.era}` + ) + ); + sendErrorResponse(ProtocolErrorCode.UnsupportedProtocolVersion, `Unsupported protocol version: ${classified}`, { + // Per spec, `supported` is the full list of protocol + // versions the receiver supports — not just the version + // this connection is on — so the peer can pick a mutually + // supported version from the error alone. + supported: this._supportedProtocolVersions, + requested: classified + }); + return; + } + } + + // Era gate — deletions are physical: a spec method that is not in + // this era's registry is −32601 BY ABSENCE, before any handler + // lookup, even when a handler is registered (a custom handler cannot + // shadow a deleted spec method across eras). Methods outside the + // spec universe are consumer-owned extension methods and stay + // era-blind. + if (isSpecRequestMethod(request.method) && !codec.hasRequestMethod(request.method)) { + sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); return; } + const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + + if (handler === undefined) { + sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); + return; + } + + // Envelope enforcement: the 2026 era requires the per-request `_meta` + // envelope on every request (spec.types.2026-07-28 RequestParams). + // The lift extracted it above; the era codec validates requiredness. + // Deliberately AFTER the era gate and the handler-existence check: + // an unknown method answers −32601 even when the envelope is also + // missing — method existence outranks parameter validity. (The + // canonical precedence table for the full inbound validation ladder + // arrives with the validation-ladder milestone; this site encodes + // only the −32601-over-−32602 rule.) + const envelopeError = codec.checkInboundEnvelope(lifted); + if (envelopeError !== undefined) { + sendErrorResponse(ProtocolErrorCode.InvalidParams, envelopeError); + return; + } + + // Related sends resolve through the SAME instance era as every other + // sender (the per-request/instance asymmetry is deliberately gone): + // the codec is resolved at send time from the connection state. + const sendNotification = (notification: Notification, options?: NotificationOptions) => + this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, { + ...options, + relatedRequestId: request.id + }); + const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => + this._requestWithSchemaViaCodec(this._resolveOutboundCodec(r.method), r, resultSchema, { + ...options, + relatedRequestId: request.id + }); + const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); @@ -650,10 +809,15 @@ export abstract class Protocol { // that overloaded property type. The cast is sound: this impl dispatches both overload paths via the // isStandardSchema guard, and sendRequest validates the result against the resolved schema either way. send: ((r: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions) => { + // Related requests resolve through the instance era at + // send time, exactly like direct sends: era-gate first, + // then method-keyed schema resolution. + const sendCodec = this._resolveOutboundCodec(r.method); + this._assertOutboundRequestInEra(sendCodec, r.method); if (isStandardSchema(schemaOrOptions)) { return sendRequest(r, schemaOrOptions, maybeOptions); } - const resultSchema = getResultSchema(r.method); + const resultSchema = sendCodec.resultSchema(r.method); if (!resultSchema) { throw new TypeError( `'${r.method}' is not a spec method; pass a result schema as the second argument to ctx.mcpReq.send().` @@ -677,8 +841,25 @@ export abstract class Protocol { return; } + // The outbound stamp seam: the era codec maps the neutral + // handler result to its wire shape. The 2025-era codec is + // the identity (never-stamp); the 2026-era codec stamps + // `resultType` and enforces the deleted-field set. A throw + // here is a NEW failure mode between handler success and + // the transport send (and the seam grows ttlMs/cacheScope + // stamping content in M3.2) — it must answer the peer with + // −32603 rather than stranding the request until timeout. + let encoded: Result; + try { + encoded = codec.encodeResult(request.method, result); + } catch (error) { + this._onerror(new Error(`Failed to encode result for ${request.method}: ${error}`)); + sendErrorResponse(ProtocolErrorCode.InternalError, 'Internal error'); + return; + } + const response: JSONRPCResponse = { - result, + result: encoded, jsonrpc: '2.0', id: request.id }; @@ -812,26 +993,91 @@ export abstract class Protocol { options?: RequestOptions ): Promise>; request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise { + const codec = this._resolveOutboundCodec(request.method); + this._assertOutboundRequestInEra(codec, request.method); if (isStandardSchema(schemaOrOptions)) { - return this._requestWithSchema(request, schemaOrOptions, maybeOptions); + return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions); } - const resultSchema = getResultSchema(request.method); + const resultSchema = codec.resultSchema(request.method); if (!resultSchema) { throw new TypeError(`'${request.method}' is not a spec method; pass a result schema as the second argument to request().`); } - return this._requestWithSchema(request, resultSchema, schemaOrOptions); + return this._requestWithSchemaViaCodec(codec, request, resultSchema, schemaOrOptions); + } + + /** + * The wire codec for this instance's negotiated era — the phase-2 truth: + * everything an established connection sends and receives resolves + * through it. Legacy until a version has been negotiated. + */ + private _negotiatedWireCodec(): WireCodec { + return codecForVersion(this._negotiatedProtocolVersion); + } + + /** + * Outbound codec resolution: while the negotiated version is still unset + * (the negotiation window), lifecycle messages are bootstrap-pinned BY + * METHOD — they self-identify their era (`initialize` IS the legacy + * handshake, `server/discover` IS the modern probe). Once a version has + * been negotiated, the instance era is authoritative for everything — a + * negotiated session never re-routes a method onto the other era. + */ + private _resolveOutboundCodec(method: string): WireCodec { + if (this._negotiatedProtocolVersion === undefined) { + const pinned = bootstrapOutboundCodec(method); + if (pinned) return pinned; + } + return this._negotiatedWireCodec(); } /** - * Sends a request and waits for a response, using the provided schema for validation. + * Era gate for outbound requests — deletions are physical in BOTH + * directions: sending a spec method that the resolved era does not define + * dies locally with a typed error before anything reaches the transport. + * Methods outside the spec universe are consumer-owned extension methods + * and stay era-blind. + */ + private _assertOutboundRequestInEra(codec: WireCodec, method: string): void { + if (isSpecRequestMethod(method) && !codec.hasRequestMethod(method)) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Method '${method}' is not supported by the negotiated protocol version (wire era ${codec.era})`, + { method, era: codec.era } + ); + } + } + + /** + * Sends a request and waits for a response, using the provided schema for + * validation instead of the era registry's method-keyed entry. * - * This is the internal implementation used by SDK methods that need to specify - * a particular result schema (e.g., for compatibility schemas). + * This is the internal implementation used by SDK methods whose result + * schema cannot be expressed as a method-keyed registry entry — the one + * surviving case is `server.createMessage`, whose result schema depends + * on the REQUEST params (tools vs no tools) — and by callers passing + * explicit compatibility schemas. Spec methods are still era-gated here: + * an explicit schema never smuggles a deleted method onto the wire. */ protected _requestWithSchema( request: Request, resultSchema: T, options?: RequestOptions + ): Promise> { + const codec = this._resolveOutboundCodec(request.method); + this._assertOutboundRequestInEra(codec, request.method); + return this._requestWithSchemaViaCodec(codec, request, resultSchema, options); + } + + /** + * The request funnel proper, keyed by the resolved era codec: the codec + * owns result decoding (raw-first `resultType` discrimination — V-1 — + * and the era's lift posture) before the schema validation step. + */ + private _requestWithSchemaViaCodec( + codec: WireCodec, + request: Request, + resultSchema: T, + options?: RequestOptions ): Promise> { const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; @@ -959,6 +1205,28 @@ export abstract class Protocol { result = rest as Result; } + // Codec decode hop (the structural V-1 home): the era codec + // applies its raw-first posture before schema validation. + // NOTE (staging): the funnel block above predates the codec + // split and still runs first; it is removed when the + // 2026-era codec lands and the codecs own the postures. + const decoded = codec.decodeResult(request.method, result); + if (decoded.kind === 'invalid') { + return reject(decoded.error); + } + if (decoded.kind === 'input_required') { + // Driver seam: the multi-round-trip driver (M4.1) + // consumes this payload; until it lands, surface the + // discriminated kind as a typed local error, no retry. + return reject( + new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type 'input_required' for ${request.method}`, { + resultType: 'input_required', + method: request.method + }) + ); + } + result = decoded.result; + validateStandardSchema(resultSchema, result).then(parseResult => { if (parseResult.success) { resolve(parseResult.data); @@ -999,10 +1267,29 @@ export abstract class Protocol { * Emits a notification, which is a one-way message that does not expect a response. */ async notification(notification: Notification, options?: NotificationOptions): Promise { + return this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, options); + } + + /** + * The notification funnel proper, keyed by the resolved era codec — + * direct sends and related notifications (`ctx.mcpReq.notify`) alike + * resolve through the instance's negotiated era at send time. + */ + private async _notificationViaCodec(codec: WireCodec, notification: Notification, options?: NotificationOptions): Promise { if (!this._transport) { throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); } + // Era gate — outbound deletions are physical for notifications too: a + // spec notification the resolved era does not define dies locally. + if (isSpecNotificationMethod(notification.method) && !codec.hasNotificationMethod(notification.method)) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Notification '${notification.method}' is not supported by the negotiated protocol version (wire era ${codec.era})`, + { method: notification.method, era: codec.era } + ); + } + this.assertNotificationCapability(notification.method); const jsonrpcNotification: JSONRPCNotification = { jsonrpc: '2.0', ...notification }; @@ -1084,18 +1371,32 @@ export abstract class Protocol { let stored: (request: JSONRPCRequest, ctx: ContextT) => Promise; if (typeof schemasOrHandler === 'function') { - const schema = getRequestSchema(method); - if (!schema) { + if (!isSpecRequestMethod(method)) { throw new TypeError( `'${method}' is not a spec request method; pass schemas as the second argument to setRequestHandler().` ); } - stored = (request, ctx) => Promise.resolve(schemasOrHandler(schema.parse(request), ctx)); + // Dispatch-time schema resolution: the request is parsed with the + // schema of the era serving this connection (the instance era at + // dispatch time), never with a schema captured at registration + // time. + stored = (request, ctx) => { + const schema = this._negotiatedWireCodec().requestSchema(method); + if (!schema) { + // Unreachable: the dispatch era gate rejects era-mismatched + // spec methods with −32601 before any handler runs. + throw new ProtocolError(ProtocolErrorCode.InternalError, `No wire schema for ${method} in the resolved era`); + } + return Promise.resolve(schemasOrHandler(schema.parse(request), ctx)); + }; } else if (maybeHandler) { stored = async (request, ctx) => { - const userParams = { ...request.params }; - delete userParams._meta; - const parsed = await validateStandardSchema(schemasOrHandler.params, userParams); + // Custom handlers receive `_meta` present-minus-reserved: the + // wire-only lift already removed the reserved envelope keys, + // and the remaining metadata (progressToken, extension keys) + // is handler material — consistent with the spec-method path. + // (Behavior migration: `_meta` used to be deleted here.) + const parsed = await validateStandardSchema(schemasOrHandler.params, { ...request.params }); if (!parsed.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error}`); } @@ -1167,13 +1468,22 @@ export abstract class Protocol { maybeHandler?: (params: unknown, notification: Notification) => void | Promise ): void { if (typeof schemasOrHandler === 'function') { - const schema = getNotificationSchema(method); - if (!schema) { + if (!isSpecNotificationMethod(method)) { throw new TypeError( `'${method}' is not a spec notification method; pass schemas as the second argument to setNotificationHandler().` ); } - this._notificationHandlers.set(method, notification => Promise.resolve(schemasOrHandler(schema.parse(notification)))); + // Dispatch-time schema resolution, same as setRequestHandler: the + // era serving the message picks the schema. + this._notificationHandlers.set(method, (notification, codec) => { + const schema = codec.notificationSchema(method); + if (!schema) { + // Unreachable: the dispatch era gate drops era-mismatched + // spec notifications before any handler runs. + throw new ProtocolError(ProtocolErrorCode.InternalError, `No wire schema for ${method} in the resolved era`); + } + return Promise.resolve(schemasOrHandler(schema.parse(notification))); + }); return; } @@ -1181,9 +1491,9 @@ export abstract class Protocol { throw new TypeError('setNotificationHandler: handler is required'); } this._notificationHandlers.set(method, async notification => { - const userParams = { ...notification.params }; - delete userParams._meta; - const parsed = await validateStandardSchema(schemasOrHandler.params, userParams); + // `_meta` present-minus-reserved, matching the custom request + // path (the lift already removed the reserved envelope keys). + const parsed = await validateStandardSchema(schemasOrHandler.params, { ...notification.params }); if (!parsed.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for notification ${method}: ${parsed.error}`); } diff --git a/packages/core/src/types/guards.ts b/packages/core/src/types/guards.ts index dd5c4765a1..8091b962c1 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -72,6 +72,12 @@ export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => J /** * Checks if a value is a valid {@linkcode CallToolResult}. + * + * This is a consumer-side VALUE check against the neutral model, not a wire + * validator: a raw wire object that additionally carries wire-only members + * (e.g. `resultType`) still passes through the loose index signature. Use a + * transport-level parse to validate raw wire traffic. + * * @param value - The value to check. * * @returns True if the value is a valid {@linkcode CallToolResult}, false otherwise. diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index 5c9d4a3f5e..eec110b960 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1,23 +1,7 @@ import * as z from 'zod/v4'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - JSONRPC_VERSION, - LOG_LEVEL_META_KEY, - PROTOCOL_VERSION_META_KEY, - RELATED_TASK_META_KEY -} from './constants.js'; -import type { - JSONArray, - JSONObject, - JSONValue, - NotificationMethod, - NotificationTypeMap, - RequestMethod, - RequestTypeMap, - ResultTypeMap -} from './types.js'; +import { JSONRPC_VERSION, RELATED_TASK_META_KEY } from './constants.js'; +import type { JSONArray, JSONObject, JSONValue } from './types.js'; export const JSONValueSchema: z.ZodType = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.string(), JSONValueSchema), z.array(JSONValueSchema)]) @@ -34,23 +18,6 @@ export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); */ export const CursorSchema = z.string(); -/** - * Task creation parameters, used to ask that the server create a task to represent a request. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const TaskCreationParamsSchema = z.looseObject({ - /** - * Requested duration in milliseconds to retain task from creation. - */ - ttl: z.number().optional(), - - /** - * Time in milliseconds to wait between task status requests. - */ - pollInterval: z.number().optional() -}); - /** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const TaskMetadataSchema = z.object({ ttl: z.number().optional() @@ -127,14 +94,13 @@ export const ResultSchema = z.looseObject({ * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on `_meta` usage. */ - _meta: RequestMetaSchema.optional(), - /** - * Indicates the type of the result, allowing the receiver to determine how to - * parse the result object. Servers implementing protocol revision 2026-07-28 or - * later always include this field; results from earlier revisions omit it, and - * an absent value must be treated as `"complete"`. - */ - resultType: z.string().optional() + _meta: RequestMetaSchema.optional() + // `resultType` is wire-only vocabulary (protocol revision 2026-07-28) and + // is deliberately NOT modeled here: the neutral result schemas carry no + // slot for it. It exists only inside the 2026-era wire codec, which + // consumes it on decode and stamps it on encode. (Q1 increment 2 - the + // former optional member here was the masking surface that let modern + // vocabulary leak through every legacy-leg parse.) }); /** @@ -694,145 +660,6 @@ export const PaginatedResultSchema = ResultSchema.extend({ nextCursor: CursorSchema.optional() }); -/** - * The status of a task. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - * */ -export const TaskStatusSchema = z.enum(['working', 'input_required', 'completed', 'failed', 'cancelled']); - -/* Tasks */ -/** - * A pollable state object associated with a request. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const TaskSchema = z.object({ - taskId: z.string(), - status: TaskStatusSchema, - /** - * Time in milliseconds to keep task results available after completion. - * If `null`, the task has unlimited lifetime until manually cleaned up. - */ - ttl: z.union([z.number(), z.null()]), - /** - * ISO 8601 timestamp when the task was created. - */ - createdAt: z.string(), - /** - * ISO 8601 timestamp when the task was last updated. - */ - lastUpdatedAt: z.string(), - pollInterval: z.optional(z.number()), - /** - * Optional diagnostic message for failed tasks or other status information. - */ - statusMessage: z.optional(z.string()) -}); - -/** - * Result returned when a task is created, containing the task data wrapped in a `task` field. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const CreateTaskResultSchema = ResultSchema.extend({ - task: TaskSchema -}); - -/** - * Parameters for task status notification. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); - -/** - * A notification sent when a task's status changes. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const TaskStatusNotificationSchema = NotificationSchema.extend({ - method: z.literal('notifications/tasks/status'), - params: TaskStatusNotificationParamsSchema -}); - -/** - * A request to get the state of a specific task. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const GetTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/get'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a {@linkcode GetTaskRequest | tasks/get} request. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const GetTaskResultSchema = ResultSchema.merge(TaskSchema); - -/** - * A request to get the result of a specific task. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const GetTaskPayloadRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/result'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a `tasks/result` request. - * The structure matches the result type of the original request. - * For example, a {@linkcode CallToolRequest | tools/call} task would return the `CallToolResult` structure. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const GetTaskPayloadResultSchema = ResultSchema.loose(); - -/** - * A request to list tasks. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ - method: z.literal('tasks/list') -}); - -/** - * The response to a {@linkcode ListTasksRequest | tasks/list} request. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const ListTasksResultSchema = PaginatedResultSchema.extend({ - tasks: z.array(TaskSchema) -}); - -/** - * A request to cancel a specific task. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const CancelTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/cancel'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a {@linkcode CancelTaskRequest | tasks/cancel} request. - * - * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. - */ -export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); - /* Resources */ /** * The contents of a specific resource or sub-resource. @@ -1472,9 +1299,9 @@ export const CallToolResultSchema = ResultSchema.extend({ * A list of content objects that represent the result of the tool call. * * If the `Tool` does not define an outputSchema, this field MUST be present in the result. - * For backwards compatibility, this field is always present, but it may be empty. + * Required on the wire per the specification (it may be an empty array). */ - content: z.array(ContentBlockSchema).default([]), + content: z.array(ContentBlockSchema), /** * An object containing structured tool output. @@ -1612,48 +1439,6 @@ export const LoggingMessageNotificationSchema = NotificationSchema.extend({ params: LoggingMessageNotificationParamsSchema }); -/* Per-request `_meta` envelope */ -/** - * The per-request `_meta` envelope carried by every request under protocol revision - * 2026-07-28: the protocol version governing the request, the client implementation - * info, and the client's capabilities — declared per request rather than once at - * initialization — plus the optional log-level opt-in. - * - * This schema models the complete envelope on its own. The base request schemas - * ({@linkcode RequestMetaSchema}) deliberately stay lenient so the same wire schemas - * parse requests from earlier protocol revisions (no envelope) as well; envelope - * requiredness is enforced per request at dispatch time, not here. - */ -export const RequestMetaEnvelopeSchema = z.looseObject({ - /** - * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. - */ - progressToken: ProgressTokenSchema.optional(), - /** - * The MCP protocol version being used for this request. For the HTTP transport, - * the value must match the `MCP-Protocol-Version` header. - */ - [PROTOCOL_VERSION_META_KEY]: z.string(), - /** - * Identifies the client software making the request. - */ - [CLIENT_INFO_META_KEY]: ImplementationSchema, - /** - * The client's capabilities for this specific request. An empty object means the - * client supports no optional capabilities. Servers must not infer capabilities - * from prior requests. - */ - [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilitiesSchema, - /** - * The desired log level for this request. When absent, the server must not send - * `notifications/message` notifications for the request. - * - * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains - * in the specification for at least twelve months. - */ - [LOG_LEVEL_META_KEY]: LoggingLevelSchema.optional() -}); - /* Sampling */ /** * Hints to use for model selection. @@ -1707,7 +1492,7 @@ export const ToolChoiceSchema = z.object({ export const ToolResultContentSchema = z.object({ type: z.literal('tool_result'), toolUseId: z.string().describe('The unique identifier for the corresponding tool call.'), - content: z.array(ContentBlockSchema).default([]), + content: z.array(ContentBlockSchema), structuredContent: z.object({}).loose().optional(), isError: z.boolean().optional(), @@ -2221,6 +2006,12 @@ export const RootsListChangedNotificationSchema = NotificationSchema.extend({ }); /* Client messages */ +// NOTE (Q1 increment 2): the role unions below are the NEUTRAL message sets. +// The 2025-era task vocabulary (tasks/* methods, task results, the task +// status notification) is 2025-only WIRE vocabulary and now lives in +// `wire/rev2025-11-25/schemas.ts`, which also exports the era's full wire +// role unions. The deprecated Task* types remain importable from the types +// barrel (Q1-SD2); they appear in no role aggregate and no API signature. export const ClientRequestSchema = z.union([ PingRequestSchema, InitializeRequestSchema, @@ -2234,19 +2025,14 @@ export const ClientRequestSchema = z.union([ SubscribeRequestSchema, UnsubscribeRequestSchema, CallToolRequestSchema, - ListToolsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema + ListToolsRequestSchema ]); export const ClientNotificationSchema = z.union([ CancelledNotificationSchema, ProgressNotificationSchema, InitializedNotificationSchema, - RootsListChangedNotificationSchema, - TaskStatusNotificationSchema + RootsListChangedNotificationSchema ]); export const ClientResultSchema = z.union([ @@ -2254,23 +2040,11 @@ export const ClientResultSchema = z.union([ CreateMessageResultSchema, CreateMessageResultWithToolsSchema, ElicitResultSchema, - ListRootsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListRootsResultSchema ]); /* Server messages */ -export const ServerRequestSchema = z.union([ - PingRequestSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - ListRootsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema -]); +export const ServerRequestSchema = z.union([PingRequestSchema, CreateMessageRequestSchema, ElicitRequestSchema, ListRootsRequestSchema]); export const ServerNotificationSchema = z.union([ CancelledNotificationSchema, @@ -2280,7 +2054,6 @@ export const ServerNotificationSchema = z.union([ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, - TaskStatusNotificationSchema, ElicitationCompleteNotificationSchema ]); @@ -2294,95 +2067,5 @@ export const ServerResultSchema = z.union([ ListResourceTemplatesResultSchema, ReadResourceResultSchema, CallToolResultSchema, - ListToolsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListToolsResultSchema ]); - -/* Runtime schema lookup — result schemas by method */ -// Keyed by `RequestMethod` so the runtime map and the typed `ResultTypeMap` -// cannot drift: `getResultSchema`'s typed overload asserts each entry parses -// to `ResultTypeMap[M]`, so no entry may be looser than the typed map -// (no task-result union members) and no key may fall outside it (no `tasks/*` -// entries — the task methods are 2025-11-25 wire vocabulary with no SDK -// runtime; callers needing task interop pass an explicit schema). -const resultSchemas: Record = { - ping: EmptyResultSchema, - initialize: InitializeResultSchema, - 'completion/complete': CompleteResultSchema, - 'logging/setLevel': EmptyResultSchema, - 'prompts/get': GetPromptResultSchema, - 'prompts/list': ListPromptsResultSchema, - 'resources/list': ListResourcesResultSchema, - 'resources/templates/list': ListResourceTemplatesResultSchema, - 'resources/read': ReadResourceResultSchema, - 'resources/subscribe': EmptyResultSchema, - 'resources/unsubscribe': EmptyResultSchema, - 'tools/call': CallToolResultSchema, - 'tools/list': ListToolsResultSchema, - 'sampling/createMessage': CreateMessageResultWithToolsSchema, - 'elicitation/create': ElicitResultSchema, - 'roots/list': ListRootsResultSchema -}; - -/** - * Gets the Zod schema for validating results of a given request method. - * Returns `undefined` for non-spec methods. - * @see getRequestSchema for explanation of the internal type assertion. - */ -export function getResultSchema(method: M): z.ZodType; -export function getResultSchema(method: string): z.ZodType | undefined; -export function getResultSchema(method: string): z.ZodType | undefined { - return resultSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; -} - -/* Runtime schema lookup — request schemas by method */ -type RequestSchemaType = (typeof ClientRequestSchema.options)[number] | (typeof ServerRequestSchema.options)[number]; -type NotificationSchemaType = (typeof ClientNotificationSchema.options)[number] | (typeof ServerNotificationSchema.options)[number]; - -function buildSchemaMap(schemas: readonly T[]): Record { - const map: Record = {}; - for (const schema of schemas) { - const method = schema.shape.method.value; - map[method] = schema; - } - return map; -} - -const requestSchemas = buildSchemaMap([...ClientRequestSchema.options, ...ServerRequestSchema.options] as const) as Record< - RequestMethod, - RequestSchemaType ->; -const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, ...ServerNotificationSchema.options] as const) as Record< - NotificationMethod, - NotificationSchemaType ->; - -/** - * Gets the Zod schema for a given request method. - * Returns `undefined` for non-spec methods. - * The return type is a ZodType that parses to RequestTypeMap[M], allowing callers - * to use schema.parse() without needing additional type assertions. - * - * Note: The internal cast is necessary because TypeScript can't correlate the - * Record-based schema lookup with the MethodToTypeMap-based RequestTypeMap - * when M is a generic type parameter. Both compute to the same type at - * instantiation, but TypeScript can't prove this statically. - */ -export function getRequestSchema(method: M): z.ZodType; -export function getRequestSchema(method: string): z.ZodType | undefined; -export function getRequestSchema(method: string): z.ZodType | undefined { - return requestSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; -} - -/** - * Gets the Zod schema for a given notification method. - * Returns `undefined` for non-spec methods. - * @see getRequestSchema for explanation of the internal type assertion. - */ -export function getNotificationSchema(method: M): z.ZodType; -export function getNotificationSchema(method: string): z.ZodType | undefined; -export function getNotificationSchema(method: string): z.ZodType | undefined { - return notificationSchemas[method as NotificationMethod] as unknown as z.ZodType | undefined; -} diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index 9da6a2f4a8..de66e99418 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -41,8 +41,6 @@ const SPEC_SCHEMA_KEYS = [ 'CallToolResultSchema', 'CancelledNotificationSchema', 'CancelledNotificationParamsSchema', - 'CancelTaskRequestSchema', - 'CancelTaskResultSchema', 'ClientCapabilitiesSchema', 'ClientNotificationSchema', 'ClientRequestSchema', @@ -56,7 +54,6 @@ const SPEC_SCHEMA_KEYS = [ 'CreateMessageRequestParamsSchema', 'CreateMessageResultSchema', 'CreateMessageResultWithToolsSchema', - 'CreateTaskResultSchema', 'CursorSchema', 'DiscoverRequestSchema', 'DiscoverResultSchema', @@ -73,10 +70,6 @@ const SPEC_SCHEMA_KEYS = [ 'GetPromptRequestSchema', 'GetPromptRequestParamsSchema', 'GetPromptResultSchema', - 'GetTaskPayloadRequestSchema', - 'GetTaskPayloadResultSchema', - 'GetTaskRequestSchema', - 'GetTaskResultSchema', 'IconSchema', 'IconsSchema', 'ImageContentSchema', @@ -103,8 +96,6 @@ const SPEC_SCHEMA_KEYS = [ 'ListResourceTemplatesResultSchema', 'ListRootsRequestSchema', 'ListRootsResultSchema', - 'ListTasksRequestSchema', - 'ListTasksResultSchema', 'ListToolsRequestSchema', 'ListToolsResultSchema', 'LoggingLevelSchema', @@ -135,7 +126,6 @@ const SPEC_SCHEMA_KEYS = [ 'RelatedTaskMetadataSchema', 'RequestSchema', 'RequestIdSchema', - 'RequestMetaEnvelopeSchema', 'RequestMetaSchema', 'ResourceSchema', 'ResourceContentsSchema', @@ -163,13 +153,8 @@ const SPEC_SCHEMA_KEYS = [ 'StringSchemaSchema', 'SubscribeRequestSchema', 'SubscribeRequestParamsSchema', - 'TaskSchema', 'TaskAugmentedRequestParamsSchema', - 'TaskCreationParamsSchema', 'TaskMetadataSchema', - 'TaskStatusSchema', - 'TaskStatusNotificationSchema', - 'TaskStatusNotificationParamsSchema', 'TextContentSchema', 'TextResourceContentsSchema', 'TitledMultiSelectEnumSchemaSchema', @@ -224,10 +209,11 @@ export type SpecTypeName = StripSchemaSuffix; * Maps each {@linkcode SpecTypeName} to its TypeScript type. * * `SpecTypes['Tool']` is equivalent to importing the `Tool` type directly. - * These are WIRE validator outputs: result entries additionally carry the - * wire-only `resultType` member, which the public result types do not declare - * (the SDK consumes it at the protocol layer and strips it before results - * reach consumers). + * These validators cover the NEUTRAL model — the consumer-facing shapes with + * no wire-only members (`resultType`, the reserved `_meta` envelope keys). + * Per-revision WIRE validators are deliberately not public surface; they are + * planned to return as versioned `zod-schemas/` exports for + * consumers who validate raw wire traffic themselves. */ export type SpecTypes = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.output> : never; diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 30b91671ab..7c8f0d30a2 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -4,6 +4,27 @@ import type * as z from 'zod/v4'; +// Wire-module schema imports, TYPE-ONLY (erased at runtime): the deprecated +// task vocabulary and the per-request envelope are wire-era artifacts whose +// schemas live in the codec modules; their inferred TYPES stay importable +// from this neutral layer (Q1-SD2). +import type { + CancelTaskRequestSchema, + CancelTaskResultSchema, + CreateTaskResultSchema, + GetTaskPayloadRequestSchema, + GetTaskPayloadResultSchema, + GetTaskRequestSchema, + GetTaskResultSchema, + ListTasksRequestSchema, + ListTasksResultSchema, + TaskCreationParamsSchema, + TaskSchema, + TaskStatusNotificationParamsSchema, + TaskStatusNotificationSchema, + TaskStatusSchema +} from '../wire/rev2025-11-25/schemas.js'; +import type { RequestMetaEnvelopeSchema } from '../wire/rev2026-07-28/schemas.js'; import type { INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, PARSE_ERROR } from './constants.js'; import type { AnnotationsSchema, @@ -17,8 +38,6 @@ import type { CallToolResultSchema, CancelledNotificationParamsSchema, CancelledNotificationSchema, - CancelTaskRequestSchema, - CancelTaskResultSchema, ClientCapabilitiesSchema, ClientNotificationSchema, ClientRequestSchema, @@ -32,7 +51,6 @@ import type { CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - CreateTaskResultSchema, CursorSchema, DiscoverRequestSchema, DiscoverResultSchema, @@ -49,10 +67,6 @@ import type { GetPromptRequestParamsSchema, GetPromptRequestSchema, GetPromptResultSchema, - GetTaskPayloadRequestSchema, - GetTaskPayloadResultSchema, - GetTaskRequestSchema, - GetTaskResultSchema, IconSchema, IconsSchema, ImageContentSchema, @@ -74,8 +88,6 @@ import type { ListResourceTemplatesResultSchema, ListRootsRequestSchema, ListRootsResultSchema, - ListTasksRequestSchema, - ListTasksResultSchema, ListToolsRequestSchema, ListToolsResultSchema, LoggingLevelSchema, @@ -106,7 +118,6 @@ import type { ReadResourceResultSchema, RelatedTaskMetadataSchema, RequestIdSchema, - RequestMetaEnvelopeSchema, RequestMetaSchema, RequestSchema, ResourceContentsSchema, @@ -136,12 +147,7 @@ import type { SubscribeRequestParamsSchema, SubscribeRequestSchema, TaskAugmentedRequestParamsSchema, - TaskCreationParamsSchema, TaskMetadataSchema, - TaskSchema, - TaskStatusNotificationParamsSchema, - TaskStatusNotificationSchema, - TaskStatusSchema, TextContentSchema, TextResourceContentsSchema, TitledMultiSelectEnumSchemaSchema, @@ -602,6 +608,38 @@ export type ListChangedHandlers = { resources?: ListChangedOptions; }; +/** + * Protocol-era classification of an inbound message. + * + * Populated by transports that classify messages at the edge (e.g. an HTTP + * entry distinguishing 2025-era from 2026-era traffic). The wire era itself + * is connection state (the negotiated protocol version held by the + * `Client`/`Server` instance); the protocol layer validates a classified + * message against that instance era at dispatch — a mismatch is treated as + * an entry/routing error, never a per-message era switch. Unclassified + * traffic is dispatched on the instance era unchanged. + */ +export interface MessageClassification { + /** + * The wire era the message was classified into: `legacy` for the + * 2025-11-25 family of revisions, `modern` for 2026-07-28 and later. + */ + era: 'legacy' | 'modern'; + + /** + * The exact protocol revision, when the classifier derived one. + */ + revision?: string; + + /** + * The per-request `_meta` envelope, when the classifier extracted it. + * Partial: whichever reserved keys the message actually carried — + * envelope requiredness is enforced per request at dispatch time, not at + * the classifying edge. + */ + envelope?: Partial; +} + /** * Extra information about a message. */ @@ -611,6 +649,14 @@ export interface MessageExtraInfo { */ request?: globalThis.Request; + /** + * Protocol-era classification of the message, when the transport + * classified it at the edge. Validated by the protocol layer against the + * instance's negotiated era at dispatch (the edge→instance handoff + * check); it does not select the era itself. + */ + classification?: MessageClassification; + /** * The authentication information. */ diff --git a/packages/core/src/wire/bootstrap.ts b/packages/core/src/wire/bootstrap.ts new file mode 100644 index 0000000000..3f54029328 --- /dev/null +++ b/packages/core/src/wire/bootstrap.ts @@ -0,0 +1,40 @@ +/** + * Static era pins for lifecycle messages on the OUTBOUND path (the + * chicken-and-egg bootstrap): these messages are sent before any negotiated + * version exists, and they self-identify their era by construction — + * `initialize`/`notifications/initialized` ARE the legacy handshake (Q2: + * `initialize` ⇒ legacy), and `server/discover` exists only on the 2026 era. + * No negotiated-state guess ever picks a payload schema for them. + * + * Scope notes: + * - OUTBOUND ONLY. Inbound era truth is per-request classification (Q2) with + * session state as fallback — pinning inbound would override the + * classifier (an unclassified `server/discover` request classifies legacy + * and correctly falls to −32601 by registry absence). + * - `ping` is deliberately NOT pinned. A bare `{method: 'ping'}` carries no + * era marker — under Q2 it classifies legacy by DEFAULT, not by + * self-identification — and pinning it would let a negotiated-modern + * session emit a 2025-only method onto the modern leg (the exact inverse + * leak registry membership exists to prevent). `ping` era-gates like any + * other method: present on the 2025 era, absent from the 2026 era (the + * modern keepalive story is owned by the negotiation milestones). + */ +import type { WireCodec } from './codec.js'; +import { codecForVersion, MODERN_WIRE_REVISION } from './codec.js'; + +export function bootstrapOutboundCodec(method: string): WireCodec | undefined { + switch (method) { + case 'initialize': + case 'notifications/initialized': { + // The legacy handshake, by definition (Q2). + return codecForVersion(undefined); + } + case 'server/discover': { + // The modern discovery exchange, 2026-era only. + return codecForVersion(MODERN_WIRE_REVISION); + } + default: { + return undefined; + } + } +} diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts new file mode 100644 index 0000000000..7e61b95363 --- /dev/null +++ b/packages/core/src/wire/codec.ts @@ -0,0 +1,206 @@ +/** + * The era-granular wire-codec layer (Q1 increment 2). + * + * The SDK separates a revision-neutral model layer (the public types — no + * `resultType`, no `_meta` envelope keys, no retry fields) from per-revision + * WIRE CODECS that own revision-exact schemas, method registries, and the + * decode (wire → neutral lift) / encode (neutral → wire stamp) transforms. + * The codec is a pure function of the negotiated protocol version, which is + * ordinary connection state on the `Protocol` instance: the client stores it + * when its handshake completes, the server stores it at `_oninitialize` (and + * modern-era server instances get it set at instance binding by the entry). + * There is no side table — era resolution is `codecForVersion()`, with the pre-negotiation window covered by the outbound method + * pins in `bootstrap.ts`. + * + * REQUIRED DISCLOSURE (Q1-SD1, era granularity): "the negotiated version + * determines which types are serialized/deserialized over the wire" cashes + * out as "the negotiated wire ERA determines them". All five legacy protocol + * versions (2024-10-07 … 2025-11-25) share one wire vocabulary and map to the + * single 2025-era codec — exactly how the single schema set already served + * all five — and '2026-07-28' maps to the 2026-era codec. A new codec exists + * only when wire vocabulary actually diverges; intra-era vocabulary is NOT + * keyed by exact version. + * + * Deletions are physical: registry membership is the deletion story. The + * 2026-era registry has no `tasks/*`, `initialize`, `ping`, `logging/setLevel`, + * `resources/(un)subscribe` or server→client wire-request entries, so an + * inbound era-mismatched method falls to −32601 by absence — even when a + * handler is registered — and an outbound one dies locally with a typed + * `SdkError` before anything reaches the transport. The 2025-era registry has + * no `server/discover`/`subscriptions/listen`/MRTR entries, symmetrically. + * + * Custom-handler shadowing policy (both directions): a method that belongs to + * the SPEC-METHOD UNIVERSE — the union of every codec's registry, derived, + * not hand-curated — is ALWAYS era-gated, so a custom handler registered for + * a deleted spec method (e.g. `tasks/get`) serves it only on the era that + * defines it. Methods outside the universe are consumer-owned extension + * methods: they are era-blind and require explicit schemas, exactly as today. + * + * Everything in `wire/` is internal to the bundled, `private: true` core — + * nothing per-revision is public surface, and nothing here may ever be + * exported from `core/public`. + */ +import type * as z from 'zod/v4'; + +import type { SdkError } from '../errors/sdkErrors.js'; +import type { + MessageClassification, + NotificationMethod, + NotificationTypeMap, + RequestMetaEnvelope, + RequestMethod, + RequestTypeMap, + Result, + ResultTypeMap +} from '../types/types.js'; +import { rev2025Codec } from './rev2025-11-25/codec.js'; + +/** Wire eras with distinct vocabulary. */ +export type WireEra = '2025-11-25' | '2026-07-28'; + +/** + * The modern wire revision literal. Internal only — deliberately NOT a public + * constant (G-D2-4: no public modern-version constant ships before era-aware + * list semantics exist). + */ +export const MODERN_WIRE_REVISION = '2026-07-28'; + +/** + * Wire-only material lifted off an inbound message by the protocol layer + * before dispatch (the V-3 seam): the reserved `_meta` envelope keys and the + * multi-round-trip driver fields. This is the typed driver-material channel + * of the codec contract — handlers never see it; the protocol layer surfaces + * it via `ctx.mcpReq.envelope` / `.inputResponses` / `.requestState`, and the + * MRTR driver (M4.1) consumes the retry fields from here. + */ +export interface LiftedWireMaterial { + // Partial: the lift surfaces whichever reserved keys the message actually + // carried — a peer on an adjacent revision may legally send a subset, and + // envelope requiredness is enforced per request at dispatch time + // (`checkInboundEnvelope`), not by the lift. + envelope?: Partial; + inputResponses?: Record; + requestState?: string; +} + +/** Result decode outcomes — the raw-first discrimination (V-1) lives in `decodeResult`. */ +export type DecodedResult = + | { + kind: 'complete'; + /** The neutral result value: wire-only material consumed/stripped. */ + result: Result; + } + | { + kind: 'input_required'; + /** + * Driver-only material (never consumer-visible). The full + * multi-round-trip driver is M4.1 scope; this seam carries the + * discriminated payload to it. + */ + inputRequests: Record; + requestState?: string; + } + | { kind: 'invalid'; error: SdkError }; + +/** + * The per-era wire codec contract (design C §3, adapted to the live funnel + * layout: the universal wire-only LIFT runs once in the protocol layer for + * every message — spec, custom, and fallback paths alike — and codecs consume + * the lifted material rather than re-implementing the strip per era). + */ +export interface WireCodec { + readonly era: WireEra; + + /** Registry membership — the deletion story (inbound −32601 by absence; outbound typed local error). */ + hasRequestMethod(method: string): boolean; + hasNotificationMethod(method: string): boolean; + + /** + * Era-exact dispatch schemas, resolved at dispatch time (never at + * registration time). The method-literal overloads carry the typed parse + * result for statically known spec methods, so call sites need no type + * assertion; `undefined` means the method has no entry on this era's + * registry. + */ + requestSchema(method: M): z.ZodType | undefined; + requestSchema(method: string): z.ZodType | undefined; + resultSchema(method: M): z.ZodType | undefined; + resultSchema(method: string): z.ZodType | undefined; + notificationSchema(method: M): z.ZodType | undefined; + notificationSchema(method: string): z.ZodType | undefined; + + /** + * Step 1 of result decoding: RAW `resultType` handling BEFORE any schema + * validation (V-1's structural home). Era postures (Q1-SD3): + * - 2026 era: required discriminator — absent ⇒ typed error naming the + * spec violation; `input_required` ⇒ driver payload; unknown ⇒ invalid, + * no retry; `complete` ⇒ consume + lift. + * - 2025 era: `resultType` is foreign vocabulary ⇒ strip-on-lift. + */ + decodeResult(method: string, raw: unknown): DecodedResult; + + /** + * Outbound result mapping (the stamp seam). The 2025-era codec is the + * identity — it has NO stamp code path (the never-stamp guarantee). The + * 2026-era codec stamps `resultType` and strictly enforces the 2026 wire + * shape for the known deleted-field set (`execution.taskSupport`, + * `capabilities.tasks` — Q1-SD3 iii). ttlMs/cacheScope stamping content + * is M3.2 scope and lands in this seam. + */ + encodeResult(method: string, result: Result): Result; + + /** + * Inbound envelope enforcement for era-classified traffic: validates the + * lifted envelope material of a request. Returns an error message when + * the era requires an envelope and it is missing/invalid (→ −32602 at the + * dispatch layer); `undefined` when acceptable. The 2025 era never + * requires an envelope. + */ + checkInboundEnvelope(material: LiftedWireMaterial): string | undefined; +} + +/** + * Era resolution, many-to-one (Q1-SD1): all `SUPPORTED_PROTOCOL_VERSIONS` + * (the five legacy versions) → the 2025-era codec; '2026-07-28' → the + * 2026-era codec; `undefined`/unknown → legacy (the DV-13 default posture — + * hand-constructed instances and unclassified traffic are legacy-era). + * + * NOTE (staging): the 2026-era codec lands with Q1 increment-2 step 5; until + * then every version resolves to the 2025-era codec and behavior is + * byte-identical to the pre-split SDK. + */ +export function codecForVersion(version: string | undefined): WireCodec { + void version; + return rev2025Codec; +} + +/** + * The wire era an edge classification names (Q2 — produced at the + * transport/entry edge; this layer only CONSUMES it). The dispatch funnel no + * longer resolves a codec FROM the classification: era is instance state, and + * a classified inbound message is VALIDATED against the instance era — a + * mismatch is an entry/routing error, never a per-message era switch. The + * exact `revision` wins over the coarse era flag when both are present. + */ +export function classifiedWireEra(classification: MessageClassification): WireEra { + if (classification.revision !== undefined) return codecForVersion(classification.revision).era; + return classification.era === 'modern' ? MODERN_WIRE_REVISION : rev2025Codec.era; +} + +/** + * The derived spec-method universe: the union of every codec registry. A + * method in this set is era-gated at dispatch and send time; a method outside + * it is a consumer-owned extension method (era-blind, schema-explicit). + * Derived from the registries — never hand-curated (the LEGACY_ONLY_METHODS + * table class is exactly what registry membership replaces). + */ +export function isSpecRequestMethod(method: string): boolean { + return ALL_CODECS.some(codec => codec.hasRequestMethod(method)); +} + +export function isSpecNotificationMethod(method: string): boolean { + return ALL_CODECS.some(codec => codec.hasNotificationMethod(method)); +} + +const ALL_CODECS: readonly WireCodec[] = [rev2025Codec]; diff --git a/packages/core/src/wire/rev2025-11-25/codec.ts b/packages/core/src/wire/rev2025-11-25/codec.ts new file mode 100644 index 0000000000..458379d9cd --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/codec.ts @@ -0,0 +1,64 @@ +/** + * The 2025-era wire codec: decode/encode ≈ identity. + * + * This codec serves every legacy protocol version (2024-10-07 … 2025-11-25). + * It is BEHAVIOR-FROZEN behind the Q10-L2 byte-identity suite — its schemas + * are today's schemas, its registry is today's method map, and its encode + * path is the identity. + * + * Never-stamp guarantee: `encodeResult` is the identity function. There is no + * stamp code path in this module — a 2025-era response cannot carry + * `resultType`, `ttlMs`, `cacheScope`, or envelope keys because no code here + * can write them, not because a stamping branch is gated off. + * + * One deliberate exception to "no 2026 code path" (Q1-SD3 ii, amending the + * V-2 'no code path at all' design claim): `decodeResult` STRIPS a foreign + * `resultType` key from inbound results before validation (strip-on-lift). + * `resultType` is not 2025 vocabulary — a 2025 peer that sends it is + * misbehaving — and the ruled posture is tolerate-and-drop so the foreign key + * can neither surface to consumers (the neutral types have no slot for it) + * nor leak through the retained loose-object passthrough. This is the ONLY + * 2026-vocabulary code path in the 2025 codec, it exists on the decode side + * only, and it deletes — never reads, maps, or emits — the foreign value. + */ +import type { Result } from '../../types/types.js'; +import type { DecodedResult, LiftedWireMaterial, WireCodec } from '../codec.js'; +import { getNotificationSchema, getRequestSchema, getResultSchema, hasNotificationMethod2025, hasRequestMethod2025 } from './registry.js'; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** The wire→neutral trust boundary: a decoded 2025-era wire result is adopted as the neutral `Result` here (the module's single deliberate assertion). */ +function toNeutralResult(value: unknown): Result { + return value as Result; +} + +export const rev2025Codec: WireCodec = { + era: '2025-11-25', + + hasRequestMethod: hasRequestMethod2025, + hasNotificationMethod: hasNotificationMethod2025, + + requestSchema: getRequestSchema, + resultSchema: getResultSchema, + notificationSchema: getNotificationSchema, + + decodeResult(_method: string, raw: unknown): DecodedResult { + // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is + // dropped before validation, whatever its value. There is no + // discrimination on this era — `resultType` carries no meaning here. + if (isPlainObject(raw) && 'resultType' in raw) { + const stripped = { ...raw }; + delete stripped['resultType']; + return { kind: 'complete', result: toNeutralResult(stripped) }; + } + return { kind: 'complete', result: toNeutralResult(raw) }; + }, + + // The never-stamp guarantee: identity. No stamp code path exists. + encodeResult: (_method: string, result: Result): Result => result, + + // The 2025 era never requires a per-request envelope. + checkInboundEnvelope: (_material: LiftedWireMaterial): string | undefined => undefined +}; diff --git a/packages/core/src/wire/rev2025-11-25/registry.ts b/packages/core/src/wire/rev2025-11-25/registry.ts new file mode 100644 index 0000000000..e865fb58ea --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/registry.ts @@ -0,0 +1,213 @@ +/** + * The 2025-era method registries — re-homed verbatim from + * `types/schemas.ts` (Q1 increment-2 step 1: mechanical relocation behind the + * codec interface; the registry CONTENT is byte-identical to the pre-split + * maps and is pinned by reference in `test/types/registryPins.test.ts`). + * + * This era serves all five legacy protocol versions (2024-10-07 … + * 2025-11-25), exactly as the single schema set did before the split. It is + * BEHAVIOR-FROZEN behind the Q10-L2 byte-identity suite: the request and + * notification maps carry the full deliberate 2025-11-25 wire vocabulary, + * including the task family (the #2248 wire-interop restore). The RESULT map + * is the runtime/typed ALIGNED map (PR #2293 review): keyed by + * `RequestMethod` so it cannot drift from the typed `ResultTypeMap` — no + * task-result union members and no `tasks/*` entries; a task-capable 2025 + * peer's `CreateTaskResult` answer fails the plain per-method schema as a + * typed invalid-result error, and callers needing task interop pass an + * explicit result schema (see `test/shared/typedMapAlignment.test.ts`). + * + * 2026-only vocabulary (`server/discover`, `subscriptions/listen`, the MRTR + * shells, `resultType`, the `_meta` envelope) has NO entry and NO code path + * here — the inverse-leak guarantee is physical absence, not discipline. + */ +import type * as z from 'zod/v4'; + +import { + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + CompleteRequestSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + InitializeResultSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema +} from '../../types/schemas.js'; +import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; +import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas.js'; +import { + CancelTaskRequestSchema, + GetTaskPayloadRequestSchema, + GetTaskRequestSchema, + ListTasksRequestSchema, + TaskStatusNotificationSchema +} from './schemas.js'; + +/* The era's wire vocabulary, derived from the wire role unions in + * `./schemas.ts` (the same unions the registries used to be built from at + * runtime). Keying the maps by these derived unions makes drift a compile + * error in BOTH directions: a union member without a map entry, a map entry + * the unions do not know, and an entry pointing at a different method's + * schema all fail to typecheck. */ +type WireRequest = z.output | z.output; +type WireNotification = z.output | z.output; + +/** Every request method in the 2025-era wire vocabulary (the typed `RequestMethod` surface plus the task family). */ +export type Rev2025RequestMethod = WireRequest['method']; +/** Every notification method in the 2025-era wire vocabulary. */ +export type Rev2025NotificationMethod = WireNotification['method']; + +/* Runtime schema lookup — result schemas by method */ +// Keyed by `RequestMethod` and valued by `z.ZodType` so the +// runtime map and the typed `ResultTypeMap` cannot drift: a missing entry, an +// extra key, or an entry that does not parse to the typed map's result type +// is a compile error. No entry may be looser than the typed map (no +// task-result union members) and no key may fall outside it (no `tasks/*` +// entries — the task methods are 2025-11-25 wire vocabulary with no SDK +// runtime; callers needing task interop pass an explicit schema). +const resultSchemas: { readonly [M in RequestMethod]: z.ZodType } = { + ping: EmptyResultSchema, + initialize: InitializeResultSchema, + 'completion/complete': CompleteResultSchema, + 'logging/setLevel': EmptyResultSchema, + 'prompts/get': GetPromptResultSchema, + 'prompts/list': ListPromptsResultSchema, + 'resources/list': ListResourcesResultSchema, + 'resources/templates/list': ListResourceTemplatesResultSchema, + 'resources/read': ReadResourceResultSchema, + 'resources/subscribe': EmptyResultSchema, + 'resources/unsubscribe': EmptyResultSchema, + 'tools/call': CallToolResultSchema, + 'tools/list': ListToolsResultSchema, + 'sampling/createMessage': CreateMessageResultWithToolsSchema, + 'elicitation/create': ElicitResultSchema, + 'roots/list': ListRootsResultSchema +}; + +/* Runtime schema lookup — request and notification schemas by method. + * + * The entries are the SAME schema objects the wire role unions are built + * from (reference identity is pinned by `test/types/registryPins.test.ts`), + * and the key order preserves the pre-split union iteration order so the + * exported method lists are byte-identical to the builder they replace. */ +const requestSchemas: { readonly [M in Rev2025RequestMethod]: z.ZodType> } = { + ping: PingRequestSchema, + initialize: InitializeRequestSchema, + 'completion/complete': CompleteRequestSchema, + 'logging/setLevel': SetLevelRequestSchema, + 'prompts/get': GetPromptRequestSchema, + 'prompts/list': ListPromptsRequestSchema, + 'resources/list': ListResourcesRequestSchema, + 'resources/templates/list': ListResourceTemplatesRequestSchema, + 'resources/read': ReadResourceRequestSchema, + 'resources/subscribe': SubscribeRequestSchema, + 'resources/unsubscribe': UnsubscribeRequestSchema, + 'tools/call': CallToolRequestSchema, + 'tools/list': ListToolsRequestSchema, + 'tasks/get': GetTaskRequestSchema, + 'tasks/result': GetTaskPayloadRequestSchema, + 'tasks/list': ListTasksRequestSchema, + 'tasks/cancel': CancelTaskRequestSchema, + 'sampling/createMessage': CreateMessageRequestSchema, + 'elicitation/create': ElicitRequestSchema, + 'roots/list': ListRootsRequestSchema +}; + +const notificationSchemas: { readonly [M in Rev2025NotificationMethod]: z.ZodType> } = { + 'notifications/cancelled': CancelledNotificationSchema, + 'notifications/progress': ProgressNotificationSchema, + 'notifications/initialized': InitializedNotificationSchema, + 'notifications/roots/list_changed': RootsListChangedNotificationSchema, + 'notifications/tasks/status': TaskStatusNotificationSchema, + 'notifications/message': LoggingMessageNotificationSchema, + 'notifications/resources/updated': ResourceUpdatedNotificationSchema, + 'notifications/resources/list_changed': ResourceListChangedNotificationSchema, + 'notifications/tools/list_changed': ToolListChangedNotificationSchema, + 'notifications/prompts/list_changed': PromptListChangedNotificationSchema, + 'notifications/elicitation/complete': ElicitationCompleteNotificationSchema +}; + +/** The 2025-era request-method set (registry membership = the deletion story). */ +export function hasRequestMethod2025(method: string): method is Rev2025RequestMethod { + return Object.prototype.hasOwnProperty.call(requestSchemas, method); +} + +/** The 2025-era notification-method set. */ +export function hasNotificationMethod2025(method: string): method is Rev2025NotificationMethod { + return Object.prototype.hasOwnProperty.call(notificationSchemas, method); +} + +/** Result-map membership: exactly the typed `RequestMethod` set (no task entries). */ +function hasResultMethod(method: string): method is RequestMethod { + return Object.prototype.hasOwnProperty.call(resultSchemas, method); +} + +/** + * Gets the Zod schema for validating results of a given request method. + * Returns `undefined` for non-spec methods. + * The typed overload is backed by the map's own typing (`z.ZodType` + * per entry), so callers with a statically known method can use the parsed + * value without a type assertion. + */ +export function getResultSchema(method: M): z.ZodType; +export function getResultSchema(method: string): z.ZodType | undefined; +export function getResultSchema(method: string): z.ZodType | undefined { + return hasResultMethod(method) ? resultSchemas[method] : undefined; +} + +/** + * Gets the Zod schema for a given request method. + * Returns `undefined` for non-spec methods. + * The typed overload returns a ZodType that parses to `RequestTypeMap[M]`, + * allowing callers to use `schema.parse()` without additional type assertions. + */ +export function getRequestSchema(method: M): z.ZodType; +export function getRequestSchema(method: string): z.ZodType | undefined; +export function getRequestSchema(method: string): z.ZodType | undefined { + return hasRequestMethod2025(method) ? requestSchemas[method] : undefined; +} + +/** + * Gets the Zod schema for a given notification method. + * Returns `undefined` for non-spec methods. + * @see getRequestSchema for the typed-overload contract. + */ +export function getNotificationSchema(method: M): z.ZodType; +export function getNotificationSchema(method: string): z.ZodType | undefined; +export function getNotificationSchema(method: string): z.ZodType | undefined { + return hasNotificationMethod2025(method) ? notificationSchemas[method] : undefined; +} + +/** Registry method lists (for the spec-method universe and the CI registry-diff oracle). */ +export const rev2025RequestMethods: readonly string[] = Object.keys(requestSchemas); +export const rev2025NotificationMethods: readonly string[] = Object.keys(notificationSchemas); diff --git a/packages/core/src/wire/rev2025-11-25/schemas.ts b/packages/core/src/wire/rev2025-11-25/schemas.ts new file mode 100644 index 0000000000..3c62d7f900 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/schemas.ts @@ -0,0 +1,326 @@ +/** + * 2025-era wire schemas: the task family (protocol revision 2025-11-25) and + * the era's full wire role unions. + * + * Everything here is 2025-only WIRE vocabulary, physically absent from the + * neutral model layer and from the 2026-era codec (Q1 increment 2 - deletions + * are physical). The task message surface was restored types-only by #2248 + * for interop with task-capable 2025 peers and is parsed ONLY through this + * era's registry; the deprecated Task* TYPES remain importable from the types + * barrel (Q1-SD2: nameability is constant, runtime availability is + * version-keyed) but appear in no API signature. + * + * Shared-tier adjudications (documented deviations from a full relocation; + * each would otherwise change frozen 2025 parse behavior, Q10-L2): + * - `RelatedTaskMetadataSchema` stays in the neutral `RequestMetaSchema`: + * `io.modelcontextprotocol/related-task` is NORMATIVE 2025-11-25 `_meta` + * vocabulary, not a leak, and the wire-only lift deliberately exempts it. + * - `TaskMetadataSchema`/`TaskAugmentedRequestParamsSchema` stay neutral: + * they are the (deprecated) `task` param member composed into the shared + * request-param schemas; removing the declared key would change strip-mode + * parsing for 2025 peers. + * - The `tasks` capability sub-schemas stay on the shared capability + * schemas for the same reason; the 2026-era codec strips `capabilities.tasks` + * on encode instead (Q1-SD3 iii). + */ +import * as z from 'zod/v4'; + +import { + BaseRequestParamsSchema, + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + ClientNotificationSchema as NeutralClientNotificationSchema, + ClientRequestSchema as NeutralClientRequestSchema, + ClientResultSchema as NeutralClientResultSchema, + CompleteRequestSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + InitializeResultSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + NotificationSchema, + NotificationsParamsSchema, + PaginatedRequestSchema, + PaginatedResultSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + RequestSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + ResultSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema +} from '../../types/schemas.js'; + +/** + * Task creation parameters, used to ask that the server create a task to represent a request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskCreationParamsSchema = z.looseObject({ + /** + * Requested duration in milliseconds to retain task from creation. + */ + ttl: z.number().optional(), + + /** + * Time in milliseconds to wait between task status requests. + */ + pollInterval: z.number().optional() +}); + +/** + * The status of a task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskStatusSchema = z.enum(['working', 'input_required', 'completed', 'failed', 'cancelled']); + +/* Tasks */ +/** + * A pollable state object associated with a request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskSchema = z.object({ + taskId: z.string(), + status: TaskStatusSchema, + /** + * Time in milliseconds to keep task results available after completion. + * If `null`, the task has unlimited lifetime until manually cleaned up. + */ + ttl: z.union([z.number(), z.null()]), + /** + * ISO 8601 timestamp when the task was created. + */ + createdAt: z.string(), + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: z.string(), + pollInterval: z.optional(z.number()), + /** + * Optional diagnostic message for failed tasks or other status information. + */ + statusMessage: z.optional(z.string()) +}); + +/** + * Result returned when a task is created, containing the task data wrapped in a `task` field. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const CreateTaskResultSchema = ResultSchema.extend({ + task: TaskSchema +}); + +/** + * Parameters for task status notification. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); + +/** + * A notification sent when a task's status changes. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskStatusNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/tasks/status'), + params: TaskStatusNotificationParamsSchema +}); + +/** + * A request to get the state of a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/get'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a {@linkcode GetTaskRequest | tasks/get} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskResultSchema = ResultSchema.merge(TaskSchema); + +/** + * A request to get the result of a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskPayloadRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/result'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a `tasks/result` request. + * The structure matches the result type of the original request. + * For example, a {@linkcode CallToolRequest | tools/call} task would return the `CallToolResult` structure. + * + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskPayloadResultSchema = ResultSchema.loose(); + +/** + * A request to list tasks. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('tasks/list') +}); + +/** + * The response to a {@linkcode ListTasksRequest | tasks/list} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const ListTasksResultSchema = PaginatedResultSchema.extend({ + tasks: z.array(TaskSchema) +}); + +/** + * A request to cancel a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const CancelTaskRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/cancel'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a {@linkcode CancelTaskRequest | tasks/cancel} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); + +/* The 2025-era wire role unions: the neutral message sets PLUS the task + * vocabulary. These are the era-faithful aggregates (what a 2025-11-25 peer + * may legally put on the wire, per role) and the source the era registry is + * built from. Member order preserves the pre-split unions (task members + * last for requests/results; notification members are method-discriminated, + * so ordering is not observable). */ +export const ClientRequestSchema = z.union([ + PingRequestSchema, + InitializeRequestSchema, + CompleteRequestSchema, + SetLevelRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema, + CallToolRequestSchema, + ListToolsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema +]); + +export const ClientNotificationSchema = z.union([ + CancelledNotificationSchema, + ProgressNotificationSchema, + InitializedNotificationSchema, + RootsListChangedNotificationSchema, + TaskStatusNotificationSchema +]); + +export const ClientResultSchema = z.union([ + EmptyResultSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitResultSchema, + ListRootsResultSchema, + GetTaskResultSchema, + ListTasksResultSchema, + CreateTaskResultSchema +]); + +export const ServerRequestSchema = z.union([ + PingRequestSchema, + CreateMessageRequestSchema, + ElicitRequestSchema, + ListRootsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema +]); + +export const ServerNotificationSchema = z.union([ + CancelledNotificationSchema, + ProgressNotificationSchema, + LoggingMessageNotificationSchema, + ResourceUpdatedNotificationSchema, + ResourceListChangedNotificationSchema, + ToolListChangedNotificationSchema, + PromptListChangedNotificationSchema, + TaskStatusNotificationSchema, + ElicitationCompleteNotificationSchema +]); + +export const ServerResultSchema = z.union([ + EmptyResultSchema, + InitializeResultSchema, + CompleteResultSchema, + GetPromptResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ReadResourceResultSchema, + CallToolResultSchema, + ListToolsResultSchema, + GetTaskResultSchema, + ListTasksResultSchema, + CreateTaskResultSchema +]); + +// Reference the imported neutral aggregates so the relationship is explicit +// for readers and tooling: the wire unions above are strict supersets. +void NeutralClientRequestSchema; +void NeutralClientNotificationSchema; +void NeutralClientResultSchema; diff --git a/packages/core/src/wire/rev2026-07-28/schemas.ts b/packages/core/src/wire/rev2026-07-28/schemas.ts new file mode 100644 index 0000000000..aaf03ab389 --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/schemas.ts @@ -0,0 +1,65 @@ +/** + * 2026-era wire schemas (protocol revision 2026-07-28). + * + * This module is the only place the per-request `_meta` envelope is modeled. + * The envelope is wire-only vocabulary: the protocol layer lifts it off + * inbound requests before any handler runs and surfaces it at + * `ctx.mcpReq.envelope`; the 2026-era codec enforces its requiredness at + * dispatch time (`checkInboundEnvelope`) - the former neutral-schema JSDoc + * deferral ("enforced per request at dispatch time, not here") is now + * discharged by that codec step. + * + * No 2025-era traffic ever touches this module, so requiredness here is + * bare and spec-exact (the shared-schema `.catch` hazards do not apply). + */ +import * as z from 'zod/v4'; + +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY +} from '../../types/constants.js'; +import { ClientCapabilitiesSchema, ImplementationSchema, LoggingLevelSchema, ProgressTokenSchema } from '../../types/schemas.js'; + +/* Per-request `_meta` envelope */ +/** + * The per-request `_meta` envelope carried by every request under protocol revision + * 2026-07-28: the protocol version governing the request, the client implementation + * info, and the client's capabilities — declared per request rather than once at + * initialization — plus the optional log-level opt-in. + * + * This schema models the complete envelope on its own (loose: foreign keys + * pass through - the lift extracts exactly the reserved keys, so enforcement + * never sees extension material). Requiredness is enforced per request at + * dispatch time by the 2026-era codec's `checkInboundEnvelope` step. + */ +export const RequestMetaEnvelopeSchema = z.looseObject({ + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken: ProgressTokenSchema.optional(), + /** + * The MCP protocol version being used for this request. For the HTTP transport, + * the value must match the `MCP-Protocol-Version` header. + */ + [PROTOCOL_VERSION_META_KEY]: z.string(), + /** + * Identifies the client software making the request. + */ + [CLIENT_INFO_META_KEY]: ImplementationSchema, + /** + * The client's capabilities for this specific request. An empty object means the + * client supports no optional capabilities. Servers must not infer capabilities + * from prior requests. + */ + [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilitiesSchema, + /** + * The desired log level for this request. When absent, the server must not send + * `notifications/message` notifications for the request. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. + */ + [LOG_LEVEL_META_KEY]: LoggingLevelSchema.optional() +}); diff --git a/packages/core/test/corpus/specCorpus.test.ts b/packages/core/test/corpus/specCorpus.test.ts index d044709485..06fc311ab2 100644 --- a/packages/core/test/corpus/specCorpus.test.ts +++ b/packages/core/test/corpus/specCorpus.test.ts @@ -38,6 +38,12 @@ import { JSONRPCResultResponseSchema } from '../../src/types/schemas.js'; import * as schemas from '../../src/types/schemas.js'; +// Era routing (Q1 increment 2): each corpus revision resolves through its own +// wire-era module first — 2025 fixtures may use 2025-only vocabulary (tasks), +// 2026 fixtures use 2026-only vocabulary (envelope, discover) — then falls +// back to the shared neutral payload schemas. +import * as wire2025 from '../../src/wire/rev2025-11-25/schemas.js'; +import * as wire2026 from '../../src/wire/rev2026-07-28/schemas.js'; const FIXTURES_ROOT = join(__dirname, 'fixtures'); @@ -78,7 +84,12 @@ const PENDING_2026_FILES: Record = { type AnyZod = z.ZodType; -function schemaFor(dir: string, fixture: unknown): AnyZod | undefined { +const ERA_SCHEMAS: Record> = { + '2025-11-25': wire2025 as Record, + '2026-07-28': wire2026 as Record +}; + +function schemaFor(revision: string, dir: string, fixture: unknown): AnyZod | undefined { if (ERROR_OBJECT_DIRS.has(dir)) { // The upstream error examples mix bare `{code, message, data?}` objects // with full JSON-RPC error responses — pick by shape. @@ -91,6 +102,8 @@ function schemaFor(dir: string, fixture: unknown): AnyZod | undefined { // tool-use array content); an example instance may be either. return z.union([CreateMessageResultSchema, CreateMessageResultWithToolsSchema]) as AnyZod; } + const eraSchema = ERA_SCHEMAS[revision]?.[`${dir}Schema`]; + if (eraSchema !== undefined) return eraSchema as AnyZod; return (schemas as Record)[`${dir}Schema`] as AnyZod | undefined; } @@ -118,12 +131,12 @@ describe.each(['2025-11-25', '2026-07-28'] as const)('spec example corpus %s', r const pendingFiles = revision === '2026-07-28' ? PENDING_2026_FILES : {}; test('every example directory is mapped to a schema or explicitly pending', () => { - const unmapped = typeDirs.filter(dir => !(dir in pending) && schemaFor(dir, {}) === undefined); + const unmapped = typeDirs.filter(dir => !(dir in pending) && schemaFor(revision, dir, {}) === undefined); expect(unmapped, 'unmapped example directories — map them or add a documented pending entry').toEqual([]); }); test('pending entries are not stale (their vocabulary is still unmodeled)', () => { - const stale = Object.keys(pending).filter(dir => schemaFor(dir, {}) !== undefined); + const stale = Object.keys(pending).filter(dir => schemaFor(revision, dir, {}) !== undefined); expect(stale, 'pending entries whose schema now exists — wire the fixtures and remove the entry').toEqual([]); // Pending entries must refer to directories that actually exist. const missing = Object.keys(pending).filter(dir => !typeDirs.includes(dir)); @@ -141,7 +154,7 @@ describe.each(['2025-11-25', '2026-07-28'] as const)('spec example corpus %s', r describe.each(mappedDirs)('%s', dir => { test.each(listFixtures(revision, dir))('%s parses', file => { const fixture = loadFixture(revision, dir, file); - const schema = schemaFor(dir, fixture); + const schema = schemaFor(revision, dir, fixture); expect(schema).toBeDefined(); const parsed = schema!.safeParse(fixture); const pendingReason = pendingFiles[`${dir}/${file}`]; diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index ffee5b9a7d..b50c376793 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -42,7 +42,15 @@ describe('Protocol custom-method support', () => { expect(result.items).toEqual(['result for hello']); }); - it('strips _meta from params before validation', async () => { + it('passes _meta to custom-handler validation, minus the reserved envelope keys (deliberate flip)', async () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): custom handlers + // used to have _meta DELETED before their params validation. They + // now receive it present-minus-reserved — the wire-only lift has + // already removed the io.modelcontextprotocol/* envelope keys — + // making the custom path consistent with the spec-method path. + // Strict consumer schemas that reject unknown keys must now model + // (or strip) _meta. Changeset: codec-split-wire-break; + // docs/migration.md "custom handlers receive _meta". const [a, b] = await pair(); const Strict = z.strictObject({ x: z.number() }); b.setRequestHandler('acme/strict', { params: Strict }, async params => { @@ -50,8 +58,20 @@ describe('Protocol custom-method support', () => { return {}; }); - const result = await a.request({ method: 'acme/strict', params: { x: 1, _meta: { progressToken: 't' } } }, z.object({})); - expect(result).toEqual({}); + // A strict schema now sees the metadata and rejects it… + await expect( + a.request({ method: 'acme/strict', params: { x: 1, _meta: { progressToken: 't' } } }, z.object({})) + ).rejects.toThrow(ProtocolError); + + // …while a schema that models _meta receives it verbatim. + const WithMeta = z.strictObject({ x: z.number(), _meta: z.record(z.string(), z.unknown()).optional() }); + let seenParams: unknown; + b.setRequestHandler('acme/withMeta', { params: WithMeta }, async params => { + seenParams = params; + return {}; + }); + await a.request({ method: 'acme/withMeta', params: { x: 2, _meta: { progressToken: 't' } } }, z.object({})); + expect(seenParams).toEqual({ x: 2, _meta: { progressToken: 't' } }); }); it('rejects invalid params with ProtocolError(InvalidParams)', async () => { @@ -112,17 +132,22 @@ describe('Protocol custom-method support', () => { expect(seen).toEqual([{ stage: 'fetch', pct: 0.5 }]); }); - it('passes the raw notification (with _meta) as the second handler argument', async () => { + it('passes _meta through custom-notification validation, minus reserved keys (deliberate flip)', async () => { + // Same behavior migration as the request path: _meta is no longer + // deleted before the consumer schema runs (ledgered; changeset: + // codec-split-wire-break). const [a, b] = await pair(); - const Strict = z.strictObject({ stage: z.string() }); + const WithMeta = z.strictObject({ stage: z.string(), _meta: z.record(z.string(), z.unknown()).optional() }); + let seenParams: unknown; let seenMeta: unknown; - b.setNotificationHandler('acme/searchProgress', { params: Strict }, (params, notification) => { - expect(params).toEqual({ stage: 'fetch' }); + b.setNotificationHandler('acme/searchProgress', { params: WithMeta }, (params, notification) => { + seenParams = params; seenMeta = notification.params?._meta; }); await a.notification({ method: 'acme/searchProgress', params: { stage: 'fetch', _meta: { traceId: 't1' } } }); await new Promise(r => setTimeout(r, 0)); + expect(seenParams).toEqual({ stage: 'fetch', _meta: { traceId: 't1' } }); expect(seenMeta).toEqual({ traceId: 't1' }); }); }); diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 6e77430d61..f488284bd5 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -22,6 +22,8 @@ import type { } from '../../src/types/index.js'; import { ProtocolError, ProtocolErrorCode } from '../../src/types/index.js'; import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import { rev2025Codec } from '../../src/wire/rev2025-11-25/codec.js'; // Test Protocol subclass for testing class TestProtocolImpl extends Protocol { @@ -910,3 +912,144 @@ describe('mergeCapabilities', () => { expect(merged).toEqual({}); }); }); + +describe('codec-seam hardening in the protocol funnels', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + + test('a throw inside codec.encodeResult answers −32603 on the wire — the peer is never stranded', async () => { + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const protocol = createTestProtocol(); + const errors: Error[] = []; + protocol.onerror = error => void errors.push(error); + protocol.setRequestHandler('acme/op', { params: z.looseObject({}) }, () => ({ ok: true }) as Result); + await protocol.connect(protocolTx); + + // The encode hop is the only throw-capable step between handler + // success and the transport send (and it grows stamping content in + // M3.2). Force it to throw once. + vi.spyOn(rev2025Codec, 'encodeResult').mockImplementationOnce(() => { + throw new Error('stamp exploded'); + }); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'acme/op', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error).toMatchObject({ code: ProtocolErrorCode.InternalError }); + // Surfaced locally too. + expect(errors.some(error => error.message.includes('Failed to encode result'))).toBe(true); + + // The connection stays serviceable: the next request round-trips. + await peerTx.send({ jsonrpc: '2.0', id: 2, method: 'acme/op', params: {} }); + await flush(); + expect(sent).toHaveLength(2); + expect((sent[1] as JSONRPCResultResponse).result).toMatchObject({ ok: true }); + + await protocol.close(); + }); +}); + +describe('inbound validation precedence: −32601 outranks envelope −32602', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + + async function wireWithFailingEnvelope(setup?: (protocol: TestProtocolImpl) => void) { + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const protocol = createTestProtocol(); + setup?.(protocol); + await protocol.connect(protocolTx); + + // Force the era's envelope check to fail for every request, so the + // test pins WHERE in the ladder it runs, independent of era wiring. + vi.spyOn(rev2025Codec, 'checkInboundEnvelope').mockImplementation(() => 'Request is missing the required _meta envelope'); + + return { peerTx, sent, flush }; + } + + test('a genuinely unknown method answers −32601 even when the envelope check would also fail', async () => { + const { peerTx, sent } = await wireWithFailingEnvelope(); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'acme/no-such-method', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error).toMatchObject({ + code: ProtocolErrorCode.MethodNotFound, + message: 'Method not found' + }); + }); + + test('a served method still answers −32602 when the envelope check fails', async () => { + const { peerTx, sent } = await wireWithFailingEnvelope(protocol => { + protocol.setRequestHandler('acme/known', { params: z.looseObject({}) }, () => ({}) as Result); + }); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'acme/known', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error).toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: 'Request is missing the required _meta envelope' + }); + }); +}); + +describe('inbound protocol-version mismatch (−32004): the error data lists every supported version', () => { + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + + test('a request classified for a protocol version this connection does not serve is rejected with the full supported list', async () => { + const supportedProtocolVersions = ['2025-11-25', '2025-06-18', '2025-03-26']; + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const protocol = new TestProtocolImpl({ supportedProtocolVersions }); + const errors: Error[] = []; + protocol.onerror = error => void errors.push(error); + await protocol.connect(protocolTx); + + // Deliver a request whose transport-edge classification names a + // protocol version this connection does not serve. The rejection's + // `data.supported` must list every protocol version the receiver + // supports — not just the version the connection is on — so the peer + // can pick a mutually supported version from the error alone. + protocolTx.onmessage?.( + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage, + // The in-memory transport's onmessage declares the narrower + // pre-classification extra type; the protocol layer reads the + // full MessageExtraInfo (same cast as the era-gate suite). + { classification: { era: 'modern' } } as never + ); + await flush(); + + expect(sent).toHaveLength(1); + const error = (sent[0] as JSONRPCErrorResponse).error as { + code: number; + message: string; + data?: { supported?: string[]; requested?: string }; + }; + expect(error.code).toBe(-32004); + expect(error.message).toContain('Unsupported protocol version'); + expect(error.data?.supported).toEqual(supportedProtocolVersions); + expect(error.data?.requested).toBe('2026-07-28'); + + await protocol.close(); + }); +}); diff --git a/packages/core/test/shared/rawResultTypeFirst.test.ts b/packages/core/test/shared/rawResultTypeFirst.test.ts index 6ccec21b95..b9710b6e42 100644 --- a/packages/core/test/shared/rawResultTypeFirst.test.ts +++ b/packages/core/test/shared/rawResultTypeFirst.test.ts @@ -84,35 +84,25 @@ describe('raw-first resultType discrimination in the request funnel', () => { await protocol.close(); }); - test('a non-string resultType can never surface as a success (rejected at message classification)', async () => { - // A response whose resultType is not a string fails the JSON-RPC - // envelope classification (the wire schema types the member), so it - // is reported out-of-band and never reaches the result funnel — and - // can therefore never be masked into a success. The funnel keeps a - // defensive raw-type check for the day classification loosens. + test('a non-string resultType can never surface as a success (rejected in the funnel)', async () => { + // Pre-codec-split, a non-string resultType died at JSON-RPC envelope + // classification because the SHARED wire schema typed the member as + // an optional string. With resultType cut from the neutral schemas + // (Q1 increment 2 — the masking surface is gone), the loose envelope + // passes the foreign key through and the funnel's defensive raw-type + // arm rejects it IN-BAND with a typed error. Either way it can never + // be masked into a success — which is the V-1 invariant this test + // exists to pin. const protocol = await wireWithRawResult({ resultType: 42, content: [] }); - const outOfBand: Error[] = []; - protocol.onerror = error => void outOfBand.push(error); - - let settled: unknown; - const pending = protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }).then( - result => { - settled = { resolved: result }; - }, - error => { - settled = { rejected: error }; - } - ); - await new Promise(resolve => setTimeout(resolve, 50)); - expect(settled, 'must not resolve as a success').toBeUndefined(); - expect(outOfBand.length).toBeGreaterThan(0); - expect(String(outOfBand[0]?.message)).toContain('Unknown message type'); + const rejection = await protocol + .request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); + expect((rejection as SdkError).data).toMatchObject({ resultType: 42 }); - // Teardown settles the in-flight request (connection closed). await protocol.close(); - await pending; - expect(settled).toHaveProperty('rejected'); }); test("resultType 'complete' is consumed: the result resolves without the wire member", async () => { diff --git a/packages/core/test/shared/typedMapAlignment.test.ts b/packages/core/test/shared/typedMapAlignment.test.ts index acd667c6cc..1cd836d3db 100644 --- a/packages/core/test/shared/typedMapAlignment.test.ts +++ b/packages/core/test/shared/typedMapAlignment.test.ts @@ -23,7 +23,9 @@ import type { BaseContext } from '../../src/shared/protocol.js'; import { Protocol } from '../../src/shared/protocol.js'; import { InMemoryTransport } from '../../src/util/inMemory.js'; import type { JSONRPCRequest } from '../../src/types/index.js'; -import { getResultSchema } from '../../src/types/index.js'; +// Post-relocation home (Q1 increment-2 step 1): the runtime registries live +// behind the per-era wire-codec interface now. +import { getResultSchema } from '../../src/wire/rev2025-11-25/registry.js'; class TestProtocol extends Protocol { protected assertCapabilityForMethod(): void {} @@ -91,20 +93,25 @@ describe('task-shaped result bodies against the narrowed runtime map', () => { await protocol.close(); }); - test('tools/call: the tolerant result schema still accepts the body (pre-existing; the old union member was unreachable)', async () => { - // Honest pin, not an endorsement: CallToolResultSchema defaults - // `content` to [] and is loose, so it accepts ANY object — including - // a task body. That made the old union's CreateTaskResultSchema - // member unreachable for tools/call (first member always matched), - // so the narrowing changes nothing observable here; the body parses - // as a content-empty CallToolResult with `task` passing through the - // loose index signature, exactly as before. Rejecting it is a result- - // schema-strictness question, out of scope for the map alignment. + test('tools/call: a CreateTaskResult body is now a typed invalid-result error too (content-default removal flip)', async () => { + // FLIPPED PIN (Q1 increment 2, ledgered with the content-default + // removal — changeset: codec-split-wire-break). The previous "Honest + // pin, not an endorsement" recorded that CallToolResultSchema's + // content.default([]) swallowed ANY object — including a task body — + // as a content-empty success, which made the old union member + // unreachable and the map narrowing observationally invisible for + // tools/call. With `content` now REQUIRED at the wire boundary the + // masking surface is gone: a task body has no `content`, fails the + // plain schema, and surfaces as the same typed invalid-result error + // as sampling/elicit. The result-schema-strictness question the old + // pin deferred is hereby resolved: loud rejection. const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); - const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); - expect(result.content).toEqual([]); - expect((result as Record).task).toEqual(CREATE_TASK_RESULT_BODY.task); + const rejection = await protocol + .request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); await protocol.close(); }); diff --git a/packages/core/test/spec.types.2025-11-25.test.ts b/packages/core/test/spec.types.2025-11-25.test.ts index 45adde80e2..40b14a43cf 100644 --- a/packages/core/test/spec.types.2025-11-25.test.ts +++ b/packages/core/test/spec.types.2025-11-25.test.ts @@ -14,6 +14,19 @@ import path from 'node:path'; import type * as SpecTypes from '../src/types/spec.types.2025-11-25.js'; import type * as SDKTypes from '../src/types/index.js'; +// The era-faithful 2025 wire role unions (Q1 increment 2): the NEUTRAL role +// aggregates no longer carry task vocabulary — the 2025-era wire module does. +// Role-union comparisons against this FROZEN revision's anchor therefore +// target the wire-era artifacts. +import type * as Wire2025 from '../src/wire/rev2025-11-25/schemas.js'; +import type * as z4 from 'zod/v4'; + +type Wire2025ClientRequest = z4.infer; +type Wire2025ClientNotification = z4.infer; +type Wire2025ClientResult = z4.infer; +type Wire2025ServerRequest = z4.infer; +type Wire2025ServerNotification = z4.infer; +type Wire2025ServerResult = z4.infer; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -220,15 +233,15 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ClientResult: (sdk: SDKTypes.ClientResult, spec: SpecTypes.ClientResult) => { + ClientResult: (sdk: Wire2025ClientResult, spec: SpecTypes.ClientResult) => { sdk = spec; spec = sdk; }, - ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { + ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { sdk = spec; spec = sdk; }, - ServerResult: (sdk: SDKTypes.ServerResult, spec: SpecTypes.ServerResult) => { + ServerResult: (sdk: Wire2025ServerResult, spec: SpecTypes.ServerResult) => { sdk = spec; spec = sdk; }, @@ -502,12 +515,12 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { + ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object` (via the InitializeRequest member); the SDK follows the 2026-07-28 schema's JSONObject sdk = spec; spec = sdk; }, - ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { + ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of CreateMessageRequest params; see the CreateMessageRequestParams check above sdk = spec; // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of CreateMessageRequest params; see the CreateMessageRequestParams check above @@ -517,7 +530,7 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { + ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { sdk = spec; spec = sdk; }, diff --git a/packages/core/test/types.test.ts b/packages/core/test/types.test.ts index a92615bceb..70b0b02a82 100644 --- a/packages/core/test/types.test.ts +++ b/packages/core/test/types.test.ts @@ -18,7 +18,6 @@ import { LOG_LEVEL_META_KEY, PromptMessageSchema, PROTOCOL_VERSION_META_KEY, - RequestMetaEnvelopeSchema, ResourceLinkSchema, ResultSchema, SamplingMessageSchema, @@ -28,6 +27,12 @@ import { ToolSchema, ToolUseContentSchema } from '../src/types/index.js'; +// Wire-era modules (Q1 increment 2): the per-request envelope lives in the +// 2026-era schemas; the era-faithful 2025 role unions (incl. tasks) live in +// the 2025-era schemas. +import { getRequestSchema } from '../src/wire/rev2025-11-25/registry.js'; +import { ClientRequestSchema as Wire2025ClientRequestSchema } from '../src/wire/rev2025-11-25/schemas.js'; +import { RequestMetaEnvelopeSchema } from '../src/wire/rev2026-07-28/schemas.js'; describe('Types', () => { test('should have correct latest protocol version', () => { @@ -291,10 +296,13 @@ describe('Types', () => { } }); - test('should validate empty content array with default', () => { - const toolResult = {}; - - const result = CallToolResultSchema.safeParse(toolResult); + test('requires content: the empty-object result no longer parses (deliberate flip)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): content.default([]) + // was removed from the wire schema (the T6 silent-empty-success + // masking root). Content is spec-required in every revision. + // Changeset: codec-split-wire-break. + expect(CallToolResultSchema.safeParse({}).success).toBe(false); + const result = CallToolResultSchema.safeParse({ content: [] }); expect(result.success).toBe(true); if (result.success) { expect(result.data.content).toEqual([]); @@ -567,6 +575,9 @@ describe('Types', () => { const toolResult = { type: 'tool_result', toolUseId: 'call_123', + // content is spec-required (the wire default([]) was removed — + // Q1 increment 2, ledgered; changeset: codec-split-wire-break). + content: [], structuredContent: { temperature: 72, condition: 'sunny' } }; @@ -583,6 +594,7 @@ describe('Types', () => { const toolResult = { type: 'tool_result', toolUseId: 'call_456', + content: [], structuredContent: { error: 'API_ERROR', message: 'Service unavailable' }, isError: true }; @@ -1025,9 +1037,15 @@ describe('Types', () => { }); describe('2025-11-25 task wire interop (task feature removed; wire types remain)', () => { - test('tasks/get parses through the client request union', () => { - const result = ClientRequestSchema.safeParse({ method: 'tasks/get', params: { taskId: 'task-123' } }); + test('tasks/get parses through the 2025-era wire request union and registry', () => { + // The task wire surface moved into the 2025-era codec module (Q1 + // increment 2): interop with task-capable 2025 peers is served by the + // era registry, and the NEUTRAL ClientRequestSchema no longer carries + // task vocabulary (deletions are physical on the 2026 era). + const result = Wire2025ClientRequestSchema.safeParse({ method: 'tasks/get', params: { taskId: 'task-123' } }); expect(result.success).toBe(true); + expect(getRequestSchema('tasks/get')).toBeDefined(); + expect(ClientRequestSchema.options.some(option => (option.shape.method.value as string) === 'tasks/get')).toBe(false); }); test('task-augmented tools/call params parse and retain the task field', () => { @@ -1148,26 +1166,25 @@ describe('2026-07-28 wire shapes', () => { }); }); - describe('Result resultType passthrough', () => { - test('accepts results with and without resultType (absent means "complete")', () => { + describe('Result resultType (cut from the neutral schemas — Q1 increment 2, ledgered)', () => { + test('the base ResultSchema no longer declares resultType; the key is loose passthrough only', () => { + // BEHAVIOR MIGRATION: the optional resultType member — the + // masking surface that let 2026 vocabulary through every + // legacy-leg parse — is gone. The wire member lives only in the + // 2026-era codec module. A foreign resultType still transits the + // loose base parse as an UNDECLARED sibling (it can no longer + // type-check, and the protocol path strips/consumes it per era). const withIt = ResultSchema.safeParse({ resultType: 'complete' }); expect(withIt.success).toBe(true); - if (withIt.success) { - expect(withIt.data.resultType).toBe('complete'); - } - const withoutIt = ResultSchema.safeParse({}); - expect(withoutIt.success).toBe(true); - if (withoutIt.success) { - expect(withoutIt.data.resultType).toBeUndefined(); - } - }); - - test('rejects a non-string resultType', () => { - expect(ResultSchema.safeParse({ resultType: 42 }).success).toBe(false); + // Non-string values are no longer schema-rejected here (the + // member is undeclared): era handling owns the raw value. + expect(ResultSchema.safeParse({ resultType: 42 }).success).toBe(true); + expect(Object.keys(ResultSchema.shape)).toEqual(['_meta']); }); - test('EmptyResult accepts resultType but still rejects unknown keys', () => { - expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(true); + test('EmptyResult rejects resultType like any unknown key (deliberate flip)', () => { + // Changeset: codec-split-wire-break. + expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(false); expect(EmptyResultSchema.safeParse({ unexpected: true }).success).toBe(false); }); }); diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts index b7985ae2c8..bb5fb64325 100644 --- a/packages/core/test/types/errorSurfacePins.test.ts +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -75,6 +75,7 @@ describe('SdkErrorCode', () => { SendFailed: 'SEND_FAILED', InvalidResult: 'INVALID_RESULT', UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', + MethodNotSupportedByProtocolVersion: 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', ClientHttpAuthentication: 'CLIENT_HTTP_AUTHENTICATION', ClientHttpForbidden: 'CLIENT_HTTP_FORBIDDEN', diff --git a/packages/core/test/types/registryPins.test.ts b/packages/core/test/types/registryPins.test.ts new file mode 100644 index 0000000000..73222b8eb5 --- /dev/null +++ b/packages/core/test/types/registryPins.test.ts @@ -0,0 +1,198 @@ +/** + * Registry byte-identity pre-pins for the wire-layer re-homing (Q1 increment 2). + * + * These tests pin the EXACT contents of the runtime method registries — + * method sets and per-method schema identity (by object reference) — so that + * relocating the registries behind the per-era codec interface is provably + * mechanical: the same schema objects must serve the same methods before and + * after the move. They are committed BEFORE the relocation lands (suite, then + * move — Q10-L2 ordering). + * + * The 2025-era registry is behavior-frozen: the request/notification maps + * carry the full deliberate 2025-11-25 wire vocabulary, including the task + * family (#2248 wire-interop restore). The RESULT map is the runtime/typed + * ALIGNED map (PR #2293 review fix): plain per-method schemas keyed by + * `RequestMethod` — no task-result union members and no `tasks/*` entries + * (task-method interop goes through the explicit-schema overload; see + * `test/shared/typedMapAlignment.test.ts` for the behavioral pins). Do not + * edit these pins to make a refactor pass; a pin change is a wire-behavior + * decision and needs a changeset + migration entry (Q10-L2). + */ +import { describe, expect, it } from 'vitest'; + +import { + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + CompleteRequestSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + InitializeResultSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema +} from '../../src/types/index.js'; +// Post-relocation home (Q1 increment-2 step 1): the pinned contents are +// unchanged — only the module housing the registries moved. +import { getNotificationSchema, getRequestSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry.js'; +// The 2025-only task wire vocabulary now lives in the era's schema module +// (Q1 increment-2 step 4); the schema OBJECTS serving the registry are the +// same — these pins still hold by reference. +import { + CancelTaskRequestSchema, + GetTaskPayloadRequestSchema, + GetTaskRequestSchema, + ListTasksRequestSchema, + TaskStatusNotificationSchema +} from '../../src/wire/rev2025-11-25/schemas.js'; + +/** The exact 2025-era request-method → schema map (today's wire surface, verbatim). */ +const EXPECTED_REQUEST_SCHEMAS = { + ping: PingRequestSchema, + initialize: InitializeRequestSchema, + 'completion/complete': CompleteRequestSchema, + 'logging/setLevel': SetLevelRequestSchema, + 'prompts/get': GetPromptRequestSchema, + 'prompts/list': ListPromptsRequestSchema, + 'resources/list': ListResourcesRequestSchema, + 'resources/templates/list': ListResourceTemplatesRequestSchema, + 'resources/read': ReadResourceRequestSchema, + 'resources/subscribe': SubscribeRequestSchema, + 'resources/unsubscribe': UnsubscribeRequestSchema, + 'tools/call': CallToolRequestSchema, + 'tools/list': ListToolsRequestSchema, + 'tasks/get': GetTaskRequestSchema, + 'tasks/result': GetTaskPayloadRequestSchema, + 'tasks/list': ListTasksRequestSchema, + 'tasks/cancel': CancelTaskRequestSchema, + 'sampling/createMessage': CreateMessageRequestSchema, + 'elicitation/create': ElicitRequestSchema, + 'roots/list': ListRootsRequestSchema +} as const; + +/** The exact 2025-era notification-method → schema map. */ +const EXPECTED_NOTIFICATION_SCHEMAS = { + 'notifications/cancelled': CancelledNotificationSchema, + 'notifications/progress': ProgressNotificationSchema, + 'notifications/initialized': InitializedNotificationSchema, + 'notifications/roots/list_changed': RootsListChangedNotificationSchema, + 'notifications/tasks/status': TaskStatusNotificationSchema, + 'notifications/message': LoggingMessageNotificationSchema, + 'notifications/resources/updated': ResourceUpdatedNotificationSchema, + 'notifications/resources/list_changed': ResourceListChangedNotificationSchema, + 'notifications/tools/list_changed': ToolListChangedNotificationSchema, + 'notifications/prompts/list_changed': PromptListChangedNotificationSchema, + 'notifications/elicitation/complete': ElicitationCompleteNotificationSchema +} as const; + +/** + * The exact 2025-era result map (the runtime/typed ALIGNED map — every entry + * is the plain schema `ResultTypeMap` declares; identity-pinned by reference). + */ +const EXPECTED_RESULT_SCHEMAS = { + ping: EmptyResultSchema, + initialize: InitializeResultSchema, + 'completion/complete': CompleteResultSchema, + 'logging/setLevel': EmptyResultSchema, + 'prompts/get': GetPromptResultSchema, + 'prompts/list': ListPromptsResultSchema, + 'resources/list': ListResourcesResultSchema, + 'resources/templates/list': ListResourceTemplatesResultSchema, + 'resources/read': ReadResourceResultSchema, + 'resources/subscribe': EmptyResultSchema, + 'resources/unsubscribe': EmptyResultSchema, + 'tools/call': CallToolResultSchema, + 'tools/list': ListToolsResultSchema, + 'sampling/createMessage': CreateMessageResultWithToolsSchema, + 'elicitation/create': ElicitResultSchema, + 'roots/list': ListRootsResultSchema +} as const; + +/** + * Task methods: served by the request map (2025 wire vocabulary, param-side + * tolerance) but deliberately ABSENT from the result map — `ResultTypeMap` + * excludes them, so the runtime map must too; callers needing task interop + * pass an explicit result schema (the documented overload). + */ +const TASK_REQUEST_METHODS = ['tasks/get', 'tasks/result', 'tasks/list', 'tasks/cancel'] as const; + +/** Methods that must NOT be in the 2025-era registries (2026-only vocabulary). */ +const NOT_IN_2025 = ['server/discover', 'subscriptions/listen', 'notifications/subscriptions/acknowledged'] as const; + +describe('2025-era registry pins (suite-then-move, Q10-L2)', () => { + it('serves exactly the pinned request methods, with the pinned schema objects', () => { + for (const [method, schema] of Object.entries(EXPECTED_REQUEST_SCHEMAS)) { + expect(getRequestSchema(method), method).toBe(schema); + } + }); + + it('serves exactly the pinned notification methods, with the pinned schema objects', () => { + for (const [method, schema] of Object.entries(EXPECTED_NOTIFICATION_SCHEMAS)) { + expect(getNotificationSchema(method), method).toBe(schema); + } + }); + + it('serves the pinned result entries by reference (aligned: plain schemas, no unions)', () => { + for (const [method, schema] of Object.entries(EXPECTED_RESULT_SCHEMAS)) { + expect(getResultSchema(method), method).toBe(schema); + } + }); + + it('serves task requests but has no task result entries (explicit-schema interop)', () => { + for (const method of TASK_REQUEST_METHODS) { + expect(getRequestSchema(method), method).toBeDefined(); + expect(getResultSchema(method), method).toBeUndefined(); + } + }); + + it('returns undefined for non-spec and 2026-only methods', () => { + for (const method of [...NOT_IN_2025, 'acme/custom', 'notifications/acme']) { + expect(getRequestSchema(method), method).toBeUndefined(); + expect(getResultSchema(method), method).toBeUndefined(); + expect(getNotificationSchema(method), method).toBeUndefined(); + } + }); + + it('the registries contain nothing beyond the pinned method sets', () => { + // Completeness guard in the inverse direction: enumerating the maps + // through their module surface must not reveal extra methods. + const requestMethods = Object.keys(EXPECTED_REQUEST_SCHEMAS).sort(); + const notificationMethods = Object.keys(EXPECTED_NOTIFICATION_SCHEMAS).sort(); + const resultMethods = Object.keys(EXPECTED_RESULT_SCHEMAS).sort(); + expect(requestMethods).toHaveLength(20); + expect(notificationMethods).toHaveLength(11); + expect(resultMethods).toHaveLength(16); + // The result-method set is exactly the request-method set minus the + // four task methods (runtime/typed alignment). + expect(resultMethods).toEqual(requestMethods.filter(method => !method.startsWith('tasks/'))); + }); +}); diff --git a/packages/core/test/types/schemaBoundaryPins.test.ts b/packages/core/test/types/schemaBoundaryPins.test.ts index 5cb1f5cccb..0f18151beb 100644 --- a/packages/core/test/types/schemaBoundaryPins.test.ts +++ b/packages/core/test/types/schemaBoundaryPins.test.ts @@ -23,9 +23,11 @@ import { JSONRPCNotificationSchema, JSONRPCRequestSchema, JSONRPCResultResponseSchema, - RequestMetaEnvelopeSchema, ResultSchema } from '../../src/types/index.js'; +// The per-request envelope is wire-only vocabulary and now lives in the +// 2026-era wire module (Q1 increment 2); its accept/reject line is unchanged. +import { RequestMetaEnvelopeSchema } from '../../src/wire/rev2026-07-28/schemas.js'; import type { CallToolResult, CompleteResult, @@ -80,10 +82,17 @@ describe('EmptyResultSchema is strict', () => { expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); }); - test('the declared _meta and resultType members are accepted', () => { + test('the declared _meta member is accepted; resultType now rejects (deliberate flip)', () => { expect(EmptyResultSchema.safeParse({}).success).toBe(true); expect(EmptyResultSchema.safeParse({ _meta: { note: 'x' } }).success).toBe(true); - expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(true); + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): `resultType` was cut + // from the base ResultSchema, so the strict empty-result ack now + // REJECTS `{resultType}` bodies at the schema level. On the protocol + // path this is invisible for conforming peers: the era codec consumes + // (2026) or strips (2025, Q1-SD3 ii) the wire member before any + // schema validation runs. Changeset: codec-split-wire-break; + // docs/migration.md "Wire schemas no longer model resultType". + expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(false); }); }); @@ -99,10 +108,18 @@ describe('typed request params strip unknown siblings', () => { }); describe('typed result schemas are loose', () => { - test('the base ResultSchema declares resultType and passes unknown siblings through', () => { + test('the base ResultSchema no longer declares resultType (the masking surface is gone)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): the optional + // `resultType` member that every legacy-leg parse silently accepted + // is cut. The key still passes the loose parse as a FOREIGN sibling + // (guards are consumer-side value checks, not wire validators), but + // no neutral schema declares it; on the protocol path the 2025-era + // codec strips it on lift (Q1-SD3 ii) and the 2026-era codec consumes + // it. Changeset: codec-split-wire-break. const parsed = ResultSchema.parse({ resultType: 'complete', futureField: 'kept' }); - expect(parsed.resultType).toBe('complete'); + expect('resultType' in parsed).toBe(true); // loose passthrough, undeclared expect((parsed as Record).futureField).toBe('kept'); + expect(Object.keys(ResultSchema.shape)).toEqual(['_meta']); }); test('unknown top-level siblings on a tools/call result survive the parse', () => { @@ -112,15 +129,20 @@ describe('typed result schemas are loose', () => { ttlMs: 5 }); expect(parsed.content).toEqual([{ type: 'text', text: 'metered' }]); - expect(parsed.resultType).toBe('complete'); + expect((parsed as Record).resultType).toBe('complete'); // undeclared foreign key, loose passthrough 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 } }); + test('CallToolResult requires content on the wire (the silent-empty-success default is gone)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): `content.default([])` + // was removed from the wire schema. The default was the T6 width-leak + // root: a task-shaped (or otherwise content-less) body parsed as a + // silent `{content: []}` success. Content is required by the spec in + // every revision; a content-less body now fails the parse LOUDLY. + // Changeset: codec-split-wire-break; docs/migration.md + // "tools/call results must include content". + expect(CallToolResultSchema.safeParse({ structuredContent: { ok: true } }).success).toBe(false); + const parsed = CallToolResultSchema.parse({ content: [], structuredContent: { ok: true } }); expect(parsed.content).toEqual([]); expect(parsed.structuredContent).toEqual({ ok: true }); }); diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 85e7d4c195..7a077717cf 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -90,15 +90,20 @@ describe('isSpecType', () => { } }); - it('narrows to the input type, not the output type, for schemas with defaults', () => { - const v: unknown = {}; + it('CallToolResult requires content at the boundary (the wire default was removed)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): CallToolResultSchema + // lost `content.default([])` — the silent-empty-success masking root. + // The guard's input shape now requires content, matching the spec in + // every revision. Changeset: codec-split-wire-break. + const empty: unknown = {}; + expect(isSpecType.CallToolResult(empty)).toBe(false); + const v: unknown = { content: [] }; expect(isSpecType.CallToolResult(v)).toBe(true); if (isSpecType.CallToolResult(v)) { - // CallToolResultSchema has `content: z.array(...).default([])`, so the input type - // permits `content` to be absent. The guard narrows to that input shape. - expectTypeOf(v.content).toEqualTypeOf(); - expectTypeOf(v).not.toEqualTypeOf(); + expectTypeOf(v.content).toEqualTypeOf(); + expectTypeOf(v.content).not.toEqualTypeOf(); } + void (0 as unknown as CallToolResult); }); it('JSONValue / JSONObject — narrows to the JSON type, not unknown', () => { @@ -134,13 +139,16 @@ describe('SpecTypeName / SpecTypes (type-level)', () => { }); it('SpecTypes[K] matches the named export type', () => { - // Result entries are WIRE validator outputs: they carry the wire-only - // `resultType` member that the public result types deliberately do not - // declare. Stripping it must yield exactly the public type — pinned in - // both directions (the wire schema keeps modeling the member). - type StripWireOnly = { [K in keyof T as K extends 'resultType' ? never : K]: T[K] }; - expectTypeOf>().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + // RE-SCOPE (Q1 increment 2, ledgered): specTypeSchemas now validate + // the NEUTRAL model. Result entries no longer carry the wire-only + // `resultType` member — the strip-then-equal pin from the public-face + // cut reverts to plain equality, and per-revision wire validators are + // deliberately NOT public surface (addable later via the versioned + // zod-schemas exports). Changeset: codec-split-wire-break. + expectTypeOf().toEqualTypeOf(); + type KnownKeys = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + type DeclaresResultType = 'resultType' extends KnownKeys ? true : false; + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/packages/core/test/types/wireOnlyHiding.test.ts b/packages/core/test/types/wireOnlyHiding.test.ts index 8b2ea7526d..1a71e600cc 100644 --- a/packages/core/test/types/wireOnlyHiding.test.ts +++ b/packages/core/test/types/wireOnlyHiding.test.ts @@ -79,9 +79,14 @@ describe('wire-only members are hidden from the public result types', () => { expect(handlerBuilt).toBeDefined(); }); - test('the wire schemas keep modeling resultType internally', () => { - expectTypeOf>>().toEqualTypeOf(); - expectTypeOf>>().toEqualTypeOf(); + test('no neutral schema models resultType any more (the masking surface is dead)', () => { + // Q1 increment 2 (ledgered): the shared schema set carried an + // optional resultType on every result parse — the masking surface. + // Post-split, NO neutral schema declares it; the member exists only + // inside the 2026-era wire codec module. Changeset: + // codec-split-wire-break. + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); }); }); @@ -125,15 +130,25 @@ describe('task vocabulary is importable but in no API signature', () => { test('the task Zod schemas and the related-task meta key carry @deprecated too', () => { // The migration docs claim the FULL task wire surface is deprecated — - // schemas and constants included, not just the inferred types. - const schemas = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'schemas.ts'), 'utf8'); - const schemaExports = [...schemas.matchAll(/export const (\w*Tasks?\w*Schema) /g)].map(match => match[1]); - expect(schemaExports.length).toBeGreaterThanOrEqual(19); - for (const name of schemaExports) { - const declaration = schemas.indexOf(`export const ${name} `); - const preceding = schemas.slice(Math.max(0, declaration - 400), declaration); - expect(preceding, `'${name}' must carry an @deprecated tag`).toContain('@deprecated'); + // schemas and constants included, not just the inferred types. The + // task MESSAGE schemas live in the 2025-era wire module since the + // codec split (Q1 increment 2); the param-side carriers stay in the + // neutral file. Both homes are scanned — the combined surface is the + // same ≥19 schemas the docs claim covers. + const neutral = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'schemas.ts'), 'utf8'); + const wire2025 = readFileSync(join(__dirname, '..', '..', 'src', 'wire', 'rev2025-11-25', 'schemas.ts'), 'utf8'); + let total = 0; + for (const schemas of [neutral, wire2025]) { + const schemaExports = [...schemas.matchAll(/export const (\w*Tasks?\w*Schema) /g)].map(match => match[1]); + total += schemaExports.length; + for (const name of schemaExports) { + const declaration = schemas.indexOf(`export const ${name} `); + const preceding = schemas.slice(Math.max(0, declaration - 400), declaration); + expect(preceding, `'${name}' must carry an @deprecated tag`).toContain('@deprecated'); + } } + expect(total).toBeGreaterThanOrEqual(19); + const schemas = neutral; // The `tasks` capability keys on both capability objects. for (const member of ['tasks: ClientTasksCapabilitySchema.optional()', 'tasks: ServerTasksCapabilitySchema.optional()']) { diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4c8dc9c9b9..a82432d968 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -34,22 +34,20 @@ import type { ToolUseContent } from '@modelcontextprotocol/core'; import { - CallToolRequestSchema, - CallToolResultSchema, + codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - ElicitResultSchema, - EmptyResultSchema, LATEST_PROTOCOL_VERSION, - ListRootsResultSchema, LoggingLevelSchema, mergeCapabilities, + negotiatedProtocolVersionOf, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode + SdkErrorCode, + setNegotiatedProtocolVersion } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -92,7 +90,6 @@ export type ServerOptions = ProtocolOptions & { export class Server extends Protocol { private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; - private _negotiatedProtocolVersion?: string; private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -207,7 +204,19 @@ export class Server extends Protocol { return handler; } return async (request, ctx) => { - const validatedRequest = parseSchema(CallToolRequestSchema, request); + // Era-exact validation: the request and result schemas come from + // the instance era, resolved at dispatch time (the era gate + // guarantees tools/call exists on the serving era). + const codec = codecForVersion(negotiatedProtocolVersionOf(this)); + const callToolRequestSchema = codec.requestSchema('tools/call'); + // The era registry entry IS the plain CallToolResult schema (the + // result map is aligned to the typed map — no widened unions), + // so no narrower surface is needed. + const callToolResultSchema = codec.resultSchema('tools/call'); + if (!callToolRequestSchema || !callToolResultSchema) { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'No wire schema for tools/call in the resolved era'); + } + const validatedRequest = parseSchema(callToolRequestSchema, request); if (!validatedRequest.success) { const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); @@ -216,7 +225,7 @@ export class Server extends Protocol { const result = await handler(request, ctx); - const validationResult = parseSchema(CallToolResultSchema, result); + const validationResult = parseSchema(callToolResultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); @@ -381,7 +390,10 @@ export class Server extends Protocol { ? requestedVersion : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); - this._negotiatedProtocolVersion = protocolVersion; + // The negotiated version is the instance's connection state — it IS + // the wire-era selection for everything this instance sends and + // receives from here on (legacy handshake ⇒ a legacy-era version). + setNegotiatedProtocolVersion(this, protocolVersion); this.transport?.setProtocolVersion?.(protocolVersion); return { @@ -412,7 +424,7 @@ export class Server extends Protocol { * `undefined` before initialization. */ getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; + return negotiatedProtocolVersionOf(this); } /** @@ -423,7 +435,7 @@ export class Server extends Protocol { } async ping(): Promise { - return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema); + return this.request({ method: 'ping' }); } /** @@ -513,11 +525,16 @@ export class Server extends Protocol { } } - // Use different schemas based on whether tools are provided + // Use different schemas based on whether tools are provided. The + // result schema depends on the REQUEST params, which a method-keyed + // registry entry cannot express, so it goes through the explicit- + // schema path (still era-gated: sampling/createMessage is not a wire + // request on the 2026 era, so a modern-era instance fails with the + // typed era error before anything reaches the transport). if (params.tools) { - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + return await this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); } - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); + return await this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); } /** @@ -537,7 +554,9 @@ export class Server extends Protocol { } const urlParams = params as ElicitRequestURLParams; - return this._requestWithSchema({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); + // Method-keyed request(): the era registry's plain + // ElicitResult schema is exactly the narrow surface. + return this.request({ method: 'elicitation/create', params: urlParams }, options); } case 'form': { if (!this._clientCapabilities?.elicitation?.form) { @@ -547,11 +566,7 @@ export class Server extends Protocol { const formParams: ElicitRequestFormParams = params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; - const result = await this._requestWithSchema( - { method: 'elicitation/create', params: formParams }, - ElicitResultSchema, - options - ); + const result = await this.request({ method: 'elicitation/create', params: formParams }, options); if (result.action === 'accept' && result.content && formParams.requestedSchema) { try { @@ -615,7 +630,7 @@ export class Server extends Protocol { * Migrate to passing paths via tool parameters, resource URIs, or configuration. */ async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise { - return this._requestWithSchema({ method: 'roots/list', params }, ListRootsResultSchema, options); + return this.request({ method: 'roots/list', params }, options); } /** diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0307681f43..4ca198535b 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -1,4 +1,4 @@ -import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import type { CallToolResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; import { InitializeResultSchema, InMemoryTransport, @@ -154,4 +154,67 @@ describe('Server', () => { await server.close(); }); }); + + describe('tools/call handler-result validation (required content)', () => { + // Server-side pin for the documented wire break (docs/migration.md, + // "CallToolResult.content … required at the wire boundary"): with the + // content.default([]) affordance removed, a handler result without + // `content` is rejected with -32602 `Invalid tools/call result` — + // never silently defaulted onto the wire — while an authored-content + // result passes through the wrapped handler untouched. + async function callToolOnServer(result: CallToolResult): Promise { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', () => result); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const received: JSONRPCMessage[] = []; + clientTransport.onmessage = message => void received.push(message); + await server.connect(serverTransport); + await clientTransport.start(); + + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + }); + await clientTransport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await clientTransport.send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: {} } }); + await new Promise(resolve => setTimeout(resolve, 10)); + await server.close(); + + const response = received.find(message => (message as { id?: unknown }).id === 2); + if (!response) { + throw new Error('no tools/call response received'); + } + return response; + } + + it('rejects a structured-only handler result (no content) with -32602 Invalid tools/call result', async () => { + const response = await callToolOnServer({ structuredContent: { ok: true } } as unknown as CallToolResult); + + const error = (response as { error?: { code: number; message: string } }).error; + expect(error).toBeDefined(); + expect(error!.code).toBe(-32602); + expect(error!.message).toContain('Invalid tools/call result'); + }); + + it('passes an authored-content result through to the wire', async () => { + const response = await callToolOnServer({ + content: [{ type: 'text', text: 'hi' }], + structuredContent: { ok: true } + }); + + if (!isJSONRPCResultResponse(response)) { + throw new Error(`Expected a result response, got: ${JSON.stringify(response)}`); + } + const result = response.result as { content: unknown; structuredContent: unknown }; + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + expect(result.structuredContent).toEqual({ ok: true }); + }); + }); }); diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index a7613b24e4..89ea643edb 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -171,6 +171,42 @@ test('should restore negotiated protocol version on transport when reconnecting expect(reconnectSetProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION); }); +/*** + * Test: The negotiated protocol version (and with it the wire era) is connection state — it must + * not survive into a fresh connect. A client whose previous connection negotiated the modern + * revision (2026-07-28) must still be able to run a FRESH initialize handshake: `initialize` is + * legacy-era vocabulary by definition (it is physically absent from the modern registry), so a + * negotiated version left over from the dead connection would otherwise kill the handshake + * locally before it reaches the transport. + */ +test('should run a fresh initialize handshake after close() when the previous connection negotiated the modern era', async () => { + const MODERN_REVISION = '2026-07-28'; + const supportedProtocolVersions = [MODERN_REVISION, ...SUPPORTED_PROTOCOL_VERSIONS]; + + const connectModern = async (client: Client) => { + const server = new Server({ name: 'modern server', version: '1.0' }, { capabilities: {}, supportedProtocolVersions }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }; + + const client = new Client({ name: 'test client', version: '1.0' }, { supportedProtocolVersions }); + + // First connection negotiates the modern revision: the instance now speaks the modern wire era. + await connectModern(client); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); + + await client.close(); + + // Fresh connect (new transport, no sessionId): the stale negotiated version is cleared, the + // handshake rides the pre-negotiation bootstrap pin (legacy era), and the connection + // can re-negotiate the modern revision. + await connectModern(client); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); + + await client.close(); +}); + /*** * Test: Reject Unsupported Protocol Version */ @@ -1769,6 +1805,9 @@ describe('outputSchema validation', () => { server.setRequestHandler('tools/call', async request => { if (request.params.name === 'test-tool') { return { + // content is spec-required (the wire default([]) was removed + // - ledgered; changeset codec-split-wire-break) + content: [], structuredContent: { result: 'success', count: 42 } }; } @@ -1844,6 +1883,7 @@ describe('outputSchema validation', () => { if (request.params.name === 'test-tool') { // Return invalid structured content (count is string instead of number) return { + content: [], structuredContent: { result: 'success', count: 'not a number' } }; } @@ -2071,6 +2111,7 @@ describe('outputSchema validation', () => { server.setRequestHandler('tools/call', async request => { if (request.params.name === 'complex-tool') { return { + content: [], structuredContent: { name: 'John Doe', age: 30, @@ -2156,6 +2197,7 @@ describe('outputSchema validation', () => { if (request.params.name === 'strict-tool') { // Return structured content with extra property return { + content: [], structuredContent: { name: 'John', extraField: 'not allowed' From 6c4c871973f7e062dfb833f61d088d8e2b8a611d Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:47:37 +0100 Subject: [PATCH 11/37] feat(core): the 2026-07-28 era codec; registry and schema CI oracles (#2295) --- .prettierignore | 4 + package.json | 1 + packages/core/src/shared/protocol.ts | 69 +- packages/core/src/types/README.md | 9 +- packages/core/src/wire/codec.ts | 11 +- .../core/src/wire/rev2025-11-25/wireTypes.ts | 163 + packages/core/src/wire/rev2026-07-28/codec.ts | 207 + .../core/src/wire/rev2026-07-28/registry.ts | 84 + .../core/src/wire/rev2026-07-28/schemas.ts | 431 +- .../schema-twins/2025-11-25.schema.json | 4058 +++++++++++++++++ .../schema-twins/2026-07-28.schema.json | 3881 ++++++++++++++++ .../test/corpus/schema-twins/manifest.json | 19 + packages/core/test/corpus/specCorpus.test.ts | 5 +- packages/core/test/shared/protocol.test.ts | 25 + .../test/shared/rawResultTypeFirst.test.ts | 197 +- .../core/test/spec.types.2025-11-25.test.ts | 82 +- .../core/test/spec.types.2026-07-28.test.ts | 415 +- .../test/types/schemaBoundaryPins.test.ts | 53 +- packages/core/test/wire/eraGates.test.ts | 572 +++ .../core/test/wire/neutralKeyParity.test.ts | 98 + .../core/test/wire/registryDiffOracle.test.ts | 104 + .../test/wire/schemaTwinConformance.test.ts | 126 + scripts/fetch-schema-twins.ts | 73 + test/e2e/scenarios/raw-result-type.test.ts | 135 +- 24 files changed, 10496 insertions(+), 326 deletions(-) create mode 100644 packages/core/src/wire/rev2025-11-25/wireTypes.ts create mode 100644 packages/core/src/wire/rev2026-07-28/codec.ts create mode 100644 packages/core/src/wire/rev2026-07-28/registry.ts create mode 100644 packages/core/test/corpus/schema-twins/2025-11-25.schema.json create mode 100644 packages/core/test/corpus/schema-twins/2026-07-28.schema.json create mode 100644 packages/core/test/corpus/schema-twins/manifest.json create mode 100644 packages/core/test/wire/eraGates.test.ts create mode 100644 packages/core/test/wire/neutralKeyParity.test.ts create mode 100644 packages/core/test/wire/registryDiffOracle.test.ts create mode 100644 packages/core/test/wire/schemaTwinConformance.test.ts create mode 100644 scripts/fetch-schema-twins.ts diff --git a/.prettierignore b/.prettierignore index 845eaaa988..0ece978310 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,6 +17,10 @@ pnpm-lock.yaml # (fetch:spec-examples) or hand-built and frozen - byte-faithful artifacts. packages/core/test/corpus/fixtures/ +# Schema twins: raw upstream schema.json bytes (fetch:schema-twins), locked to +# manifest.json by sha256 in schemaTwinConformance - reformatting breaks the lock. +packages/core/test/corpus/schema-twins/ + # Batch test cloned repos and results packages/codemod/batch-test/repos packages/codemod/batch-test/results diff --git a/package.json b/package.json index 848b5ab273..03c4132988 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "mcp" ], "scripts": { + "fetch:schema-twins": "tsx scripts/fetch-schema-twins.ts", "fetch:spec-examples": "tsx scripts/fetch-spec-examples.ts", "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", "sync:snippets": "tsx scripts/sync-snippets.ts", diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 0c8d99ab43..f9b9555171 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1162,55 +1162,26 @@ export abstract class Protocol { return reject(response); } - // Raw-first result discrimination (protocol revision - // 2026-07-28): inspect `resultType` BEFORE any schema - // validation, so a non-complete result can never be masked - // into a hollow success by a tolerant result schema (e.g. - // defaults filling in absent members). - let result = response.result; - if (isPlainObject(result) && result['resultType'] !== undefined) { - const rawResultType = result['resultType']; - if (typeof rawResultType !== 'string') { - // Defense in depth, not a reachable rejection today: - // the wire schema types `resultType` as a string, so - // message classification rejects a non-string carrier - // before it can reach this funnel (the request then - // hangs until timeout — the pre-existing failure mode - // for malformed responses). The arm stays so the - // raw-first check is self-contained if classification - // ever loosens. - return reject( - new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: non-string resultType`, { - resultType: rawResultType - }) - ); - } - if (rawResultType !== 'complete') { - // Surface the discriminated kind; no retry. This arm - // is replaced by full multi-round-trip handling when - // the client driver lands. - return reject( - new SdkError( - SdkErrorCode.UnsupportedResultType, - `Unsupported result type '${rawResultType}' for ${request.method}`, - { resultType: rawResultType, method: request.method } - ) - ); - } - // 'complete': the SDK consumes the wire discriminator; - // strip it before validation so consumers receive the - // public result shape. - const rest = { ...result }; - delete rest['resultType']; - result = rest as Result; + // Codec decode hop — the structural V-1 home. The era codec + // owns the raw-first resultType postures (Q1-SD3): + // - 2026 era: REQUIRED discriminator; absent → typed error + // naming the spec violation; input_required → driver seam; + // unknown kind → invalid, no retry; complete → wire-exact + // parse then lift. + // - 2025 era: resultType is foreign vocabulary → strip-on- + // lift, then today's schema validation decides. + // Either way a non-complete body can never be masked into a + // hollow success by a tolerant result schema. + // Guarded: this callback runs synchronously inside + // `_onresponse`, so a throw out of the decode hop would + // otherwise propagate into the transport's onmessage instead + // of failing this request. + let decoded: ReturnType; + try { + decoded = codec.decodeResult(request.method, response.result); + } catch (error) { + return reject(error instanceof Error ? error : new Error(String(error))); } - - // Codec decode hop (the structural V-1 home): the era codec - // applies its raw-first posture before schema validation. - // NOTE (staging): the funnel block above predates the codec - // split and still runs first; it is removed when the - // 2026-era codec lands and the codecs own the postures. - const decoded = codec.decodeResult(request.method, result); if (decoded.kind === 'invalid') { return reject(decoded.error); } @@ -1225,7 +1196,7 @@ export abstract class Protocol { }) ); } - result = decoded.result; + const result = decoded.result; validateStandardSchema(resultSchema, result).then(parseResult => { if (parseResult.success) { diff --git a/packages/core/src/types/README.md b/packages/core/src/types/README.md index 23c1a1e7c6..6d235ec8ae 100644 --- a/packages/core/src/types/README.md +++ b/packages/core/src/types/README.md @@ -17,5 +17,10 @@ They are reference-only test oracles: the comparison suites in `packages/core/te 3. **The bot proposes; it never auto-merges.** Automated refreshes always go through a pull request that a maintainer reviews and merges. No automation pushes anchor changes directly to `main` or merges its own PRs. A refresh PR that breaks the comparison suites is the desired signal — it is fixed in that PR, not bypassed. -4. **Generated twins update atomically with their anchor.** If artifacts derived from an anchor (for example vendored JSON schemas or generated validators) are ever checked into this repository, any refresh that changes the anchor must regenerate those artifacts in the same - commit. The anchor and its derived twins must never be out of sync at any commit on `main`. This clause becomes operative the day such generated artifacts are first vendored. +4. **Generated twins update atomically with their anchor.** If artifacts derived from an anchor (for example vendored JSON schemas or generated validators) are checked into this repository, any refresh that changes the anchor must regenerate those artifacts in the same commit. + The anchor and its derived twins must never be out of sync at any commit on `main`. + + **This clause is OPERATIVE.** The vendored twins are the per-revision `schema.json` copies under `packages/core/test/corpus/schema-twins/` (`.schema.json` + `manifest.json` recording the source commit and content hashes). They are TEST-ONLY oracles consumed by the + schema-twin conformance lock (`test/wire/schemaTwinConformance.test.ts`) — never bundled, never imported by runtime code, and the JSON Schema engines stay optional peer dependencies. A refresh of `spec.types..ts` must copy the matching upstream + `schema/

/schema.json` (same spec commit) over the twin and update `manifest.json` in the same commit; the spec example corpus manifest (`test/corpus/fixtures//manifest.json`) records its own source commit and follows the same atomicity rule when the examples + are re-vendored. The conformance lock failing after an anchor-only refresh is the desired loud signal of a missed twin update. diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index 7e61b95363..586cb2e044 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -55,6 +55,7 @@ import type { ResultTypeMap } from '../types/types.js'; import { rev2025Codec } from './rev2025-11-25/codec.js'; +import { rev2026Codec } from './rev2026-07-28/codec.js'; /** Wire eras with distinct vocabulary. */ export type WireEra = '2025-11-25' | '2026-07-28'; @@ -166,13 +167,9 @@ export interface WireCodec { * 2026-era codec; `undefined`/unknown → legacy (the DV-13 default posture — * hand-constructed instances and unclassified traffic are legacy-era). * - * NOTE (staging): the 2026-era codec lands with Q1 increment-2 step 5; until - * then every version resolves to the 2025-era codec and behavior is - * byte-identical to the pre-split SDK. */ export function codecForVersion(version: string | undefined): WireCodec { - void version; - return rev2025Codec; + return version === MODERN_WIRE_REVISION ? rev2026Codec : rev2025Codec; } /** @@ -185,7 +182,7 @@ export function codecForVersion(version: string | undefined): WireCodec { */ export function classifiedWireEra(classification: MessageClassification): WireEra { if (classification.revision !== undefined) return codecForVersion(classification.revision).era; - return classification.era === 'modern' ? MODERN_WIRE_REVISION : rev2025Codec.era; + return classification.era === 'modern' ? rev2026Codec.era : rev2025Codec.era; } /** @@ -203,4 +200,4 @@ export function isSpecNotificationMethod(method: string): boolean { return ALL_CODECS.some(codec => codec.hasNotificationMethod(method)); } -const ALL_CODECS: readonly WireCodec[] = [rev2025Codec]; +const ALL_CODECS: readonly WireCodec[] = [rev2025Codec, rev2026Codec]; diff --git a/packages/core/src/wire/rev2025-11-25/wireTypes.ts b/packages/core/src/wire/rev2025-11-25/wireTypes.ts new file mode 100644 index 0000000000..f1c116ccad --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/wireTypes.ts @@ -0,0 +1,163 @@ +/** + * 2025-era WIRE-VIEW types: the anchor-exact 2025-11-25 shapes for the names + * whose NEUTRAL public types deliberately follow the 2026-07-28 typing. + * + * This module is the visible home of the shared-tier ADJUDICATIONS that the + * old `@ts-expect-error` affordances used to suppress (Q1 increment 2): each + * override below names a field where the 2025 anchor and the neutral model + * disagree, states which side the neutral model follows, and is pinned both + * ways by the per-revision parity suite (spec.types.2025-11-25.test.ts + * compares THESE types against the frozen anchor exactly — zero affordances). + * + * RUNTIME NOTE (Q10-L2): the 2025-era runtime schemas are BEHAVIOR-FROZEN + * and deliberately stay tolerant-wider than these wire views where the + * neutral typing is wider (e.g. `experimental` values accept any JSONObject + * at parse). These types pin the WIRE-LEVEL shape contract against the + * anchor; they do not narrow runtime acceptance. + * + * Adjudication ledger (neutral follows 2026 unless stated): + * - `Tool.inputSchema`/`outputSchema` property values: 2025 wire `object`; + * neutral follows 2026 (`JSONValue`-capable open schema objects). + * - capability blobs (`experimental`, `sampling`, `elicitation`, `tasks`, + * `logging`, `completions`): 2025 wire `object`; neutral `JSONObject`. + * - `extensions` capability key: 2026-only; absent from the 2025 wire view. + * - `CreateMessageRequestParams.metadata`: 2025 wire `object`; neutral + * `JSONObject`. + * - `PromptArgument.title` / `PromptReference.title`: present on the 2025 + * wire (BaseMetadata); the neutral schemas do not declare it and the + * strip-mode parse drops it (PRE-EXISTING runtime gap, recorded in the + * project baseline-bug log — do not silently change parse behavior here). + */ +import type { + CallToolRequest, + CancelTaskRequest, + ClientCapabilities, + CompleteRequest, + CreateMessageRequest, + CreateMessageRequestParams, + ElicitRequest, + GetPromptRequest, + GetTaskPayloadRequest, + GetTaskRequest, + InitializeRequest, + InitializeRequestParams, + InitializeResult, + ListPromptsRequest, + ListResourcesRequest, + ListResourceTemplatesRequest, + ListRootsRequest, + ListTasksRequest, + ListToolsRequest, + ListToolsResult, + PingRequest, + PromptArgument, + PromptReference, + ReadResourceRequest, + ServerCapabilities, + SetLevelRequest, + SubscribeRequest, + Tool, + UnsubscribeRequest +} from '../../types/types.js'; + +/** The 2025 anchor types blob values as bare `object`. */ +type ObjectMap = { [key: string]: object }; + +/** + * Omit that survives loose (index-signature) source types: the plain `Omit` + * collapses named keys into the index signature (`Pick`), which + * silently weakens the pins. Key-remapping preserves both. + */ +type OmitKnown = { [P in keyof T as P extends K ? never : P]: T[P] }; + +/** 2025 wire shape of tool input/output schemas (property values are `object`). */ +export type Wire2025ToolIOSchema = { + $schema?: string; + type: 'object'; + properties?: ObjectMap; + required?: string[]; +}; + +export type Wire2025Tool = OmitKnown & { + inputSchema: Wire2025ToolIOSchema; + outputSchema?: Wire2025ToolIOSchema; +}; + +export type Wire2025ListToolsResult = OmitKnown & { tools: Wire2025Tool[] }; + +export type Wire2025ClientCapabilities = OmitKnown< + ClientCapabilities, + 'extensions' | 'experimental' | 'sampling' | 'elicitation' | 'tasks' +> & { + experimental?: ObjectMap; + sampling?: { context?: object; tools?: object }; + elicitation?: { form?: object; url?: object }; + tasks?: { + list?: object; + cancel?: object; + requests?: { sampling?: { createMessage?: object }; elicitation?: { create?: object } }; + }; +}; + +export type Wire2025ServerCapabilities = OmitKnown< + ServerCapabilities, + 'extensions' | 'experimental' | 'logging' | 'completions' | 'tasks' +> & { + experimental?: ObjectMap; + logging?: object; + completions?: object; + tasks?: { + list?: object; + cancel?: object; + requests?: { tools?: { call?: object } }; + }; +}; + +export type Wire2025InitializeRequestParams = OmitKnown & { + capabilities: Wire2025ClientCapabilities; +}; + +export type Wire2025InitializeRequest = OmitKnown & { params: Wire2025InitializeRequestParams }; + +export type Wire2025InitializeResult = OmitKnown & { capabilities: Wire2025ServerCapabilities }; + +export type Wire2025CreateMessageRequestParams = OmitKnown & { + metadata?: object; + tools?: Wire2025Tool[]; +}; + +export type Wire2025CreateMessageRequest = OmitKnown & { params: Wire2025CreateMessageRequestParams }; + +/** 2025 wire: `title` is a declared BaseMetadata member (the neutral schemas do not model it — see ledger above). */ +export type Wire2025PromptArgument = PromptArgument & { title?: string }; +export type Wire2025PromptReference = PromptReference & { title?: string }; + +/** The 2025 wire role unions with the adjudicated members substituted. */ +export type Wire2025ClientRequestView = + | PingRequest + | Wire2025InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; + +export type Wire2025ServerRequestView = + | PingRequest + | Wire2025CreateMessageRequest + | ElicitRequest + | ListRootsRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; diff --git a/packages/core/src/wire/rev2026-07-28/codec.ts b/packages/core/src/wire/rev2026-07-28/codec.ts new file mode 100644 index 0000000000..9e3e4f25ef --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/codec.ts @@ -0,0 +1,207 @@ +/** + * The 2026-era wire codec (protocol revision 2026-07-28). + * + * Decode = raw-first `resultType` discrimination (the structural V-1 home: + * the RAW value is inspected BEFORE any schema validation, so a non-complete + * result can never be masked into a hollow success by a tolerant schema), + * then wire-exact parse, then lift (drop the wire member). Encode = the + * stamp seam: `resultType: 'complete'` is stamped on outbound results, and + * the known deleted-field set is strictly enforced (Q1-SD3 iii) — the 2026 + * wire types have no slot for `execution.taskSupport` or + * `capabilities.tasks`, so the encode mapping deletes them; era-blind + * handlers stay era-invisible while deleted vocabulary cannot cross eras + * through the parse-free outbound path. + * + * Q1-SD3 postures implemented here: + * (i) absent `resultType` from a 2026-classified peer → typed error NAMING + * the violation. The spec's absent⇒complete bridge is scoped to + * EARLIER-revision servers (spec.types.2026-07-28.ts Result.resultType: + * "Servers implementing this protocol version MUST include this field") + * and is deliberately NOT extended to modern traffic. + * (ii) `input_required` → the driver-seam payload (the multi-round-trip + * driver, M4.1/#13, consumes it; until then the protocol layer surfaces + * the discriminated kind as a typed local error, no retry). + * (iii) unrecognized kinds → invalid, no retry (DQ5). + * + * The ttlMs/cacheScope stamping content (M3.2) lands in `encodeResult` — + * this seam is its final home. + */ +import type * as z from 'zod/v4'; + +import { SdkError, SdkErrorCode } from '../../errors/sdkErrors.js'; +import type { Result } from '../../types/types.js'; +import type { DecodedResult, LiftedWireMaterial, WireCodec } from '../codec.js'; +import { + getNotificationSchema2026, + getRequestSchema2026, + getResultSchema2026, + hasNotificationMethod2026, + hasRequestMethod2026 +} from './registry.js'; +import { + CallToolResultSchema, + CompleteResultSchema, + DiscoverResultSchema, + GetPromptResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ListToolsResultSchema, + ReadResourceResultSchema, + RequestMetaEnvelopeSchema +} from './schemas.js'; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Strip the known deleted-field set from an outbound result (Q1-SD3 iii). */ +function enforceDeletedFields(method: string, result: Result): Result { + let next: Record = result as Record; + let copied = false; + const copy = () => { + if (!copied) { + next = { ...next }; + copied = true; + } + return next; + }; + + // tools arrays: execution (the taskSupport carrier) is deleted vocabulary. + const tools = (result as { tools?: unknown }).tools; + if (method === 'tools/list' && Array.isArray(tools) && tools.some(tool => isPlainObject(tool) && 'execution' in tool)) { + copy().tools = tools.map(tool => { + if (!isPlainObject(tool) || !('execution' in tool)) return tool; + const rest = { ...tool }; + delete rest['execution']; + return rest; + }); + } + + // capability objects: the `tasks` capability is deleted vocabulary. + const capabilities = (result as { capabilities?: unknown }).capabilities; + if (isPlainObject(capabilities) && 'tasks' in capabilities) { + const rest = { ...capabilities }; + delete rest['tasks']; + copy().capabilities = rest; + } + + return next as Result; +} + +export const rev2026Codec: WireCodec = { + era: '2026-07-28', + + hasRequestMethod: hasRequestMethod2026, + hasNotificationMethod: hasNotificationMethod2026, + + requestSchema: getRequestSchema2026, + resultSchema: getResultSchema2026, + notificationSchema: getNotificationSchema2026, + + decodeResult(method: string, raw: unknown): DecodedResult { + if (!isPlainObject(raw)) { + return { + kind: 'invalid', + error: new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${method}: not an object`, { method }) + }; + } + + // Step 1 — RAW discrimination, before any schema (V-1). + const rawResultType = raw['resultType']; + if (rawResultType === undefined) { + // Q1-SD3 (i): hard error naming the violation. + return { + kind: 'invalid', + error: new SdkError( + SdkErrorCode.InvalidResult, + `Invalid result for ${method}: missing required resultType — servers implementing protocol revision 2026-07-28 ` + + `MUST include it (the absent-means-complete bridge applies only to earlier-revision servers)`, + { method, violation: 'missing-resultType' } + ) + }; + } + if (typeof rawResultType !== 'string') { + return { + kind: 'invalid', + error: new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${method}: non-string resultType`, { + method, + resultType: rawResultType + }) + }; + } + if (rawResultType === 'input_required') { + // The driver seam (#13 consumes this payload). + const inputRequests = raw['inputRequests']; + return { + kind: 'input_required', + inputRequests: isPlainObject(inputRequests) ? inputRequests : {}, + ...(typeof raw['requestState'] === 'string' && { requestState: raw['requestState'] }) + }; + } + if (rawResultType !== 'complete') { + // Unrecognized kind ⇒ invalid, no retry (DQ5). + return { + kind: 'invalid', + error: new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type '${rawResultType}' for ${method}`, { + resultType: rawResultType, + method + }) + }; + } + + // Step 2 — wire-exact parse (registry methods), with resultType present. + // Own-key lookup: `method` is peer-influenced on related-request + // paths, and a prototype-chain hit (e.g. 'constructor') must not + // masquerade as a schema and throw out of the decode hop. + const wireSchema = Object.hasOwn(WIRE_RESULT_SCHEMAS, method) ? WIRE_RESULT_SCHEMAS[method] : undefined; + if (wireSchema !== undefined) { + const parsed = wireSchema.safeParse(raw); + if (!parsed.success) { + return { + kind: 'invalid', + error: new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${method}: ${parsed.error}`, { method }) + }; + } + } + + // Step 3 — lift: the wire discriminator is consumed. + const lifted = { ...raw }; + delete lifted['resultType']; + return { kind: 'complete', result: lifted as Result }; + }, + + encodeResult(method: string, result: Result): Result { + // The stamp seam: outbound results carry the required discriminator. + // (Handler-authored resultType for methods whose vocabulary exceeds + // 'complete' is MRTR scope — #13 extends this seam.) + return { ...enforceDeletedFields(method, result), resultType: 'complete' } as Result; + }, + + checkInboundEnvelope(material: LiftedWireMaterial): string | undefined { + if (material.envelope === undefined) { + return ( + 'Request is missing the required _meta envelope for protocol revision 2026-07-28 ' + + '(io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientInfo, io.modelcontextprotocol/clientCapabilities)' + ); + } + const parsed = RequestMetaEnvelopeSchema.safeParse(material.envelope); + if (!parsed.success) { + return `Invalid _meta envelope for protocol revision 2026-07-28: ${parsed.error.issues.map(issue => issue.message).join('; ')}`; + } + return undefined; + } +}; + +/** Wire-true result wrappers consulted by decode step 2, keyed by method. */ +const WIRE_RESULT_SCHEMAS: Record = { + 'tools/call': CallToolResultSchema, + 'tools/list': ListToolsResultSchema, + 'prompts/get': GetPromptResultSchema, + 'prompts/list': ListPromptsResultSchema, + 'resources/list': ListResourcesResultSchema, + 'resources/templates/list': ListResourceTemplatesResultSchema, + 'resources/read': ReadResourceResultSchema, + 'completion/complete': CompleteResultSchema, + 'server/discover': DiscoverResultSchema +}; diff --git a/packages/core/src/wire/rev2026-07-28/registry.ts b/packages/core/src/wire/rev2026-07-28/registry.ts new file mode 100644 index 0000000000..e361e65eff --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/registry.ts @@ -0,0 +1,84 @@ +/** + * The 2026-era method registries (protocol revision 2026-07-28). + * + * Registry membership IS the deletion story: there are NO entries for + * `initialize`, `notifications/initialized`, `ping`, `logging/setLevel`, + * `resources/subscribe`, `resources/unsubscribe`, + * `notifications/roots/list_changed`, the task family, or the server→client + * wire-request channel — so an era-mismatched method falls to −32601 by + * absence inbound and a typed local error outbound, with no table to forget. + * + * HAND-REGISTRY SEED DECISIONS (pinned by the CI registry-diff oracle, which + * fails LOUD if this list and the anchor diff ever disagree): + * - `sampling/createMessage`, `elicitation/create`, `roots/list`: the anchor + * still carries their method literals on bare interfaces, but 2026 DEMOTES + * them from wire requests to in-band `InputRequest` payloads — the entire + * server→client JSON-RPC request channel is deleted (`ServerRequest` has + * no 2026 export). A generator walking method literals would re-admit them + * (the ATK-D flavor-b trap); this hand registry excludes them by + * construction. Their in-band role lands with the MRTR driver (#13). + * - `subscriptions/listen` + `notifications/subscriptions/acknowledged` + * (SEP-1865): 2026-only vocabulary whose SHELLS land with the + * subscriptions feature (#14). Until then they are absent here — inbound + * listen gets −32601 (capability not yet served), which is protocol-legal + * for a server that does not implement subscriptions. + */ +import type * as z from 'zod/v4'; + +import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; +import type { Rev2026NotificationMethod, Rev2026RequestMethod } from './schemas.js'; +import { dispatchRequestSchemas, dispatchResultSchemas, notificationSchemas2026 } from './schemas.js'; + +/** The 2026-era request-method set (registry membership = the deletion story). */ +export function hasRequestMethod2026(method: string): method is Rev2026RequestMethod { + return Object.prototype.hasOwnProperty.call(dispatchRequestSchemas, method); +} + +/** The 2026-era notification-method set. */ +export function hasNotificationMethod2026(method: string): method is Rev2026NotificationMethod { + return Object.prototype.hasOwnProperty.call(notificationSchemas2026, method); +} + +/** Result-map membership (same key set as the request map on this era). */ +function hasResultMethod2026(method: string): method is Rev2026RequestMethod { + return Object.prototype.hasOwnProperty.call(dispatchResultSchemas, method); +} + +/** + * Gets the dispatch (post-lift) Zod schema for a given request method. + * Returns `undefined` for methods this era's registry does not define. + * The typed overload mirrors `WireCodec.requestSchema` so call sites with a + * statically known method need no type assertion. + */ +export function getRequestSchema2026(method: M): z.ZodType | undefined; +export function getRequestSchema2026(method: string): z.ZodType | undefined; +export function getRequestSchema2026(method: string): z.ZodType | undefined { + return hasRequestMethod2026(method) ? dispatchRequestSchemas[method] : undefined; +} + +/** + * Gets the dispatch (post-lift) Zod schema for validating results of a given + * request method. Returns `undefined` for methods this era's registry does + * not define. + * @see getRequestSchema2026 for the typed-overload contract. + */ +export function getResultSchema2026(method: M): z.ZodType | undefined; +export function getResultSchema2026(method: string): z.ZodType | undefined; +export function getResultSchema2026(method: string): z.ZodType | undefined { + return hasResultMethod2026(method) ? dispatchResultSchemas[method] : undefined; +} + +/** + * Gets the Zod schema for a given notification method. + * Returns `undefined` for methods this era's registry does not define. + * @see getRequestSchema2026 for the typed-overload contract. + */ +export function getNotificationSchema2026(method: M): z.ZodType | undefined; +export function getNotificationSchema2026(method: string): z.ZodType | undefined; +export function getNotificationSchema2026(method: string): z.ZodType | undefined { + return hasNotificationMethod2026(method) ? notificationSchemas2026[method] : undefined; +} + +/** Registry method lists (for the spec-method universe and the CI registry-diff oracle). */ +export const rev2026RequestMethods: readonly string[] = Object.keys(dispatchRequestSchemas); +export const rev2026NotificationMethods: readonly string[] = Object.keys(notificationSchemas2026); diff --git a/packages/core/src/wire/rev2026-07-28/schemas.ts b/packages/core/src/wire/rev2026-07-28/schemas.ts index aaf03ab389..510cd399ef 100644 --- a/packages/core/src/wire/rev2026-07-28/schemas.ts +++ b/packages/core/src/wire/rev2026-07-28/schemas.ts @@ -20,7 +20,62 @@ import { LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../types/constants.js'; -import { ClientCapabilitiesSchema, ImplementationSchema, LoggingLevelSchema, ProgressTokenSchema } from '../../types/schemas.js'; +import { + AnnotationsSchema, + AudioContentSchema, + BaseMetadataSchema, + BlobResourceContentsSchema, + CancelledNotificationSchema, + ClientCapabilitiesSchema, + ContentBlockSchema, + CursorSchema, + ElicitationCompleteNotificationSchema, + IconsSchema, + ImageContentSchema, + ImplementationSchema, + LoggingLevelSchema, + LoggingMessageNotificationSchema, + ProgressNotificationSchema, + ProgressTokenSchema, + PromptListChangedNotificationSchema, + PromptMessageSchema, + PromptReferenceSchema, + PromptSchema, + ResourceContentsSchema, + ResourceListChangedNotificationSchema, + ResourceSchema, + ResourceTemplateReferenceSchema, + ResourceTemplateSchema, + ResourceUpdatedNotificationSchema, + RoleSchema, + ServerCapabilitiesSchema, + TextContentSchema, + TextResourceContentsSchema, + ToolAnnotationsSchema, + ToolListChangedNotificationSchema, + ToolUseContentSchema +} from '../../types/schemas.js'; + +/* 2026-era capability forks (defined ahead of the envelope, which composes + * the client fork). The shared shapes minus the deleted `tasks` key: `tasks` + * is 2025-only vocabulary with no slot on this revision, consistent with the + * encode-side deletion (Q1-SD3 iii). + * + * The client fork lists its members EXPLICITLY (composing the shared member + * schemas by reference) rather than using `.omit()`: the envelope schema + * below reaches the bundled package declarations, and an `.omit()` inference + * is a mapped type whose printed member order is unstable across dts-rollup + * builds (api-report flap). The explicit list doubles as the fork's deletion + * statement — a member added to the shared shape must be re-adjudicated here. */ +const sharedClientCapabilityShape = ClientCapabilitiesSchema.shape; +export const ClientCapabilities2026Schema = z.object({ + experimental: sharedClientCapabilityShape.experimental, + sampling: sharedClientCapabilityShape.sampling, + elicitation: sharedClientCapabilityShape.elicitation, + roots: sharedClientCapabilityShape.roots, + extensions: sharedClientCapabilityShape.extensions +}); +export const ServerCapabilities2026Schema = ServerCapabilitiesSchema.omit({ tasks: true }); /* Per-request `_meta` envelope */ /** @@ -51,9 +106,11 @@ export const RequestMetaEnvelopeSchema = z.looseObject({ /** * The client's capabilities for this specific request. An empty object means the * client supports no optional capabilities. Servers must not infer capabilities - * from prior requests. + * from prior requests. Validated with the 2026 fork: `tasks` has no slot on + * this revision (deleted vocabulary), matching the server-side fork wired + * into `DiscoverResultSchema`. */ - [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilitiesSchema, + [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilities2026Schema, /** * The desired log level for this request. When absent, the server must not send * `notifications/message` notifications for the request. @@ -63,3 +120,371 @@ export const RequestMetaEnvelopeSchema = z.looseObject({ */ [LOG_LEVEL_META_KEY]: LoggingLevelSchema.optional() }); + +/* ------------------------------------------------------------------------ * + * Forked payload vocabulary (shared-tier admission rule, ATK-B section 1): + * `Tool` and `SamplingMessage` are bidirectionally incomparable between the + * 2025-11-25 and 2026-07-28 anchors, so they FORK per wire module instead of + * sitting in the shared tier. The forks below are 2026-anchor-exact: + * - Tool (2026) has NO `execution` member (ToolExecution and its + * `taskSupport` carrier are deleted vocabulary) — a 2026 peer's tool that + * carries one is stripped on parse, and the encode side strips it from + * outbound tools (Q1-SD3 iii). + * - SamplingMessage (2026) is composed against the 2026 anchor shape. + * ------------------------------------------------------------------------ */ + +/** 2026-era Tool: anchor-exact — no `execution` (deleted vocabulary). */ +export const ToolSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + description: z.string().optional(), + // Anchor-exact: { $schema?: string; type: 'object'; [key: string]: unknown } + inputSchema: z.looseObject({ + $schema: z.string().optional(), + type: z.literal('object') + }), + // Anchor-exact: { $schema?: string; [key: string]: unknown } + outputSchema: z + .looseObject({ + $schema: z.string().optional() + }) + .optional(), + annotations: ToolAnnotationsSchema.optional(), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** 2026-era ToolResultContent (anchor-exact: `structuredContent?: unknown`). */ +export const ToolResultContentSchema = z.object({ + type: z.literal('tool_result'), + toolUseId: z.string(), + content: z.array(ContentBlockSchema), + structuredContent: z.unknown().optional(), + isError: z.boolean().optional(), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** 2026-era sampling content union (composes the forked tool-result shape). */ +export const SamplingMessageContentBlockSchema = z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ToolUseContentSchema, + ToolResultContentSchema +]); + +/** 2026-era SamplingMessage (anchor-exact: single block or array). */ +export const SamplingMessageSchema = z.object({ + role: RoleSchema, + content: z.union([SamplingMessageContentBlockSchema, z.array(SamplingMessageContentBlockSchema)]), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/* ------------------------------------------------------------------------ * + * Result side. `resultType` is REQUIRED at parse (spec.types.2026-07-28 + * Result.resultType: "Servers implementing this protocol version MUST + * include this field"); requiredness is bare because no 2025-era traffic + * touches this module. These are the WIRE-TRUE artifacts — the corpus and + * the parity suite parse them; `decodeResult` parses with them and then + * LIFTS (drops resultType) to the neutral shape. + * ------------------------------------------------------------------------ */ + +/** Open union per the anchor: 'complete' | 'input_required' | string. */ +export const ResultTypeSchema = z.string(); + +const wireMeta = z.record(z.string(), z.unknown()).optional(); + +function wireResult(shape: T) { + return z.looseObject({ + _meta: wireMeta, + /** REQUIRED on this revision (see module header). */ + resultType: ResultTypeSchema, + ...shape + }); +} + +export const ResultSchema = wireResult({}); + +export const PaginatedResultSchema = wireResult({ + nextCursor: CursorSchema.optional() +}); + +export const CallToolResultSchema = wireResult({ + content: z.array(ContentBlockSchema), + structuredContent: z.unknown().optional(), + isError: z.boolean().optional() +}); + +export const ListToolsResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + tools: z.array(ToolSchema), + nextCursor: CursorSchema.optional() +}); + +export const ListPromptsResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + prompts: z.array(PromptSchema), + nextCursor: CursorSchema.optional() +}); + +export const GetPromptResultSchema = wireResult({ + description: z.string().optional(), + messages: z.array(PromptMessageSchema) +}); + +export const ListResourcesResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + resources: z.array(ResourceSchema), + nextCursor: CursorSchema.optional() +}); + +export const ListResourceTemplatesResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + resourceTemplates: z.array(ResourceTemplateSchema), + nextCursor: CursorSchema.optional() +}); + +export const ReadResourceResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + contents: z.array(z.union([TextResourceContentsSchema, BlobResourceContentsSchema])) +}); + +export const CompleteResultSchema = wireResult({ + completion: z + .object({ + values: z.array(z.string()).max(100), + total: z.number().int().optional(), + hasMore: z.boolean().optional() + }) + .loose() +}); + +/** CacheableResult (SEP-2549): ttlMs and cacheScope REQUIRED per the anchor. */ +export const CacheableResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']) +}); + +export const DiscoverResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + supportedVersions: z.array(z.string()), + capabilities: ServerCapabilities2026Schema, + serverInfo: ImplementationSchema, + instructions: z.string().optional() +}); + +/* ------------------------------------------------------------------------ * + * Request side. Two views per method: + * - WIRE-TRUE (`RequestSchema`): params `_meta` carries the REQUIRED + * envelope (anchor RequestParams._meta is required). The corpus and parity + * suite consume these. + * - DISPATCH (post-lift, internal to the registry): the protocol layer's + * universal lift has already extracted the envelope, so dispatch parses a + * 2025-like shape with optional `_meta` (progressToken/extension keys + * only) and NO 2025-only members (`task` is undeclared and strips — + * payload-level deletion is physical on this leg). + * ------------------------------------------------------------------------ */ + +/** Post-lift request `_meta` (progressToken + extension keys; loose). */ +const DispatchRequestMetaSchema = z.looseObject({ + progressToken: ProgressTokenSchema.optional() +}); + +function wireRequest(method: M, paramsShape: T) { + return z.object({ + method: z.literal(method), + params: z.object({ _meta: RequestMetaEnvelopeSchema, ...paramsShape }) + }); +} + +function dispatchRequest(method: M, paramsShape: T) { + return z.object({ + method: z.literal(method), + params: z.object({ _meta: DispatchRequestMetaSchema.optional(), ...paramsShape }).optional() + }); +} + +const callToolParamsShape = { + name: z.string(), + arguments: z.record(z.string(), z.unknown()).optional() +}; +const paginatedParamsShape = { cursor: CursorSchema.optional() }; + +export const CallToolRequestSchema = wireRequest('tools/call', callToolParamsShape); +export const ListToolsRequestSchema = wireRequest('tools/list', paginatedParamsShape); +export const ListPromptsRequestSchema = wireRequest('prompts/list', paginatedParamsShape); +export const GetPromptRequestSchema = wireRequest('prompts/get', { + name: z.string(), + arguments: z.record(z.string(), z.string()).optional() +}); +export const ListResourcesRequestSchema = wireRequest('resources/list', paginatedParamsShape); +export const ListResourceTemplatesRequestSchema = wireRequest('resources/templates/list', paginatedParamsShape); +export const ReadResourceRequestSchema = wireRequest('resources/read', { uri: z.string() }); +const completeParamsShape = { + ref: z.union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), + argument: z.object({ name: z.string(), value: z.string() }), + context: z.object({ arguments: z.record(z.string(), z.string()).optional() }).optional() +}; +export const CompleteRequestSchema = wireRequest('completion/complete', completeParamsShape); +export const DiscoverRequestSchema = wireRequest('server/discover', {}); + +/** + * The 2026-era request-method set — the hand-registry seed (see registry.ts + * for the seed decisions). The dispatch maps below are mapped types over this + * union, so a missing entry, an extra entry, or an entry pointing at another + * method's schema is a compile error; the CI registry-diff oracle pins the + * same set against the anchor at runtime. + */ +export type Rev2026RequestMethod = + | 'tools/call' + | 'tools/list' + | 'prompts/get' + | 'prompts/list' + | 'resources/list' + | 'resources/templates/list' + | 'resources/read' + | 'completion/complete' + | 'server/discover'; + +/** Dispatch (post-lift) request schemas, keyed by method — registry-internal. */ +export const dispatchRequestSchemas: { readonly [M in Rev2026RequestMethod]: z.ZodType<{ method: M }> } = { + 'tools/call': dispatchRequest('tools/call', callToolParamsShape), + 'tools/list': dispatchRequest('tools/list', paginatedParamsShape), + 'prompts/get': dispatchRequest('prompts/get', { + name: z.string(), + arguments: z.record(z.string(), z.string()).optional() + }), + 'prompts/list': dispatchRequest('prompts/list', paginatedParamsShape), + 'resources/list': dispatchRequest('resources/list', paginatedParamsShape), + 'resources/templates/list': dispatchRequest('resources/templates/list', paginatedParamsShape), + 'resources/read': dispatchRequest('resources/read', { uri: z.string() }), + 'completion/complete': dispatchRequest('completion/complete', completeParamsShape), + 'server/discover': dispatchRequest('server/discover', {}) +}; + +/** Dispatch (post-lift) result schemas, keyed by method — what the funnel + * validates AFTER `decodeResult` consumed `resultType`. */ +function liftedResult(shape: T) { + return z.looseObject({ _meta: wireMeta, ...shape }); +} + +export const dispatchResultSchemas: { readonly [M in Rev2026RequestMethod]: z.ZodType } = { + 'tools/call': liftedResult({ + content: z.array(ContentBlockSchema), + structuredContent: z.unknown().optional(), + isError: z.boolean().optional() + }), + 'tools/list': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + tools: z.array(ToolSchema), + nextCursor: CursorSchema.optional() + }), + 'prompts/get': liftedResult({ + description: z.string().optional(), + messages: z.array(PromptMessageSchema) + }), + 'prompts/list': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + prompts: z.array(PromptSchema), + nextCursor: CursorSchema.optional() + }), + 'resources/list': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + resources: z.array(ResourceSchema), + nextCursor: CursorSchema.optional() + }), + 'resources/templates/list': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + resourceTemplates: z.array(ResourceTemplateSchema), + nextCursor: CursorSchema.optional() + }), + 'resources/read': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + contents: z.array(z.union([TextResourceContentsSchema, BlobResourceContentsSchema])) + }), + 'completion/complete': liftedResult({ + completion: z + .object({ + values: z.array(z.string()).max(100), + total: z.number().int().optional(), + hasMore: z.boolean().optional() + }) + .loose() + }), + 'server/discover': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + supportedVersions: z.array(z.string()), + capabilities: ServerCapabilities2026Schema, + serverInfo: ImplementationSchema, + instructions: z.string().optional() + }) +}; + +/* ------------------------------------------------------------------------ * + * Notifications. The 2026 notification set: cancelled, progress, message, + * resources/updated, resources/list_changed, tools/list_changed, + * prompts/list_changed, elicitation/complete. Deleted: initialized, + * roots/list_changed, tasks/status. The shapes are revision-identical to the + * shared schemas, which are composed by reference. (The 2026-only + * subscriptions/acknowledged notification is #14 scope — see registry.ts.) + * ------------------------------------------------------------------------ */ +/** The 2026-era notification-method set (the hand-registry seed; see the deletion list above). */ +export type Rev2026NotificationMethod = + | 'notifications/cancelled' + | 'notifications/progress' + | 'notifications/message' + | 'notifications/resources/updated' + | 'notifications/resources/list_changed' + | 'notifications/tools/list_changed' + | 'notifications/prompts/list_changed' + | 'notifications/elicitation/complete'; + +export const notificationSchemas2026: { readonly [M in Rev2026NotificationMethod]: z.ZodType<{ method: M }> } = { + 'notifications/cancelled': CancelledNotificationSchema, + 'notifications/progress': ProgressNotificationSchema, + 'notifications/message': LoggingMessageNotificationSchema, + 'notifications/resources/updated': ResourceUpdatedNotificationSchema, + 'notifications/resources/list_changed': ResourceListChangedNotificationSchema, + 'notifications/tools/list_changed': ToolListChangedNotificationSchema, + 'notifications/prompts/list_changed': PromptListChangedNotificationSchema, + 'notifications/elicitation/complete': ElicitationCompleteNotificationSchema +}; + +/* ------------------------------------------------------------------------ * + * Response envelopes (wire-true; parity/corpus artifacts). + * ------------------------------------------------------------------------ */ +const wireResultResponse = (result: T) => + z + .object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number().int()]), + result + }) + .strict(); + +export const JSONRPCResultResponseSchema = wireResultResponse(ResultSchema); +export const CallToolResultResponseSchema = wireResultResponse(CallToolResultSchema); +export const ListToolsResultResponseSchema = wireResultResponse(ListToolsResultSchema); +export const ListPromptsResultResponseSchema = wireResultResponse(ListPromptsResultSchema); +export const GetPromptResultResponseSchema = wireResultResponse(GetPromptResultSchema); +export const ListResourcesResultResponseSchema = wireResultResponse(ListResourcesResultSchema); +export const ListResourceTemplatesResultResponseSchema = wireResultResponse(ListResourceTemplatesResultSchema); +export const ReadResourceResultResponseSchema = wireResultResponse(ReadResourceResultSchema); +export const CompleteResultResponseSchema = wireResultResponse(CompleteResultSchema); +export const DiscoverResultResponseSchema = wireResultResponse(DiscoverResultSchema); + +// Referenced by reference to keep the compose-by-reference relationships +// explicit for tooling (these shared payloads serve both eras unchanged). +void AnnotationsSchema; +void ResourceContentsSchema; diff --git a/packages/core/test/corpus/schema-twins/2025-11-25.schema.json b/packages/core/test/corpus/schema-twins/2025-11-25.schema.json new file mode 100644 index 0000000000..9d2e662a26 --- /dev/null +++ b/packages/core/test/corpus/schema-twins/2025-11-25.schema.json @@ -0,0 +1,4058 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended audience of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/$defs/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BaseMetadata": { + "description": "Base interface for metadata with name (identifier) and title (display name) properties.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CallToolRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CallToolRequestParams": { + "description": "Parameters for a `tools/call` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "arguments": { + "additionalProperties": {}, + "description": "Arguments to use for the tool call.", + "type": "object" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The server's response to a tool call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "A list of content objects that represent the unstructured result of the tool call.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional JSON object that represents the structured result of the tool call.", + "type": "object" + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "CancelTaskRequest": { + "description": "A request to cancel a task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/cancel", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to cancel.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelTaskResult": { + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "The response to a tasks/cancel request." + }, + "CancelledNotification": { + "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.\n\nA client MUST NOT attempt to cancel its `initialize` request.\n\nFor task cancellation, use the `tasks/cancel` request instead of this notification.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelledNotificationParams": { + "description": "Parameters for a `notifications/cancelled` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/$defs/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction.\nThis MUST be provided for cancelling non-task requests.\nThis MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead)." + } + }, + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "elicitation": { + "description": "Present if the client supports elicitation from the server.", + "properties": { + "form": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "url": { + "additionalProperties": true, + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": { + "listChanged": { + "description": "Whether the client supports notifications for changes to the roots list.", + "type": "boolean" + } + }, + "type": "object" + }, + "sampling": { + "description": "Present if the client supports sampling from an LLM.", + "properties": { + "context": { + "additionalProperties": true, + "description": "Whether the client supports context inclusion via includeContext parameter.\nIf not declared, servers SHOULD only use `includeContext: \"none\"` (or omit it).", + "properties": {}, + "type": "object" + }, + "tools": { + "additionalProperties": true, + "description": "Whether the client supports tool use via tools and toolChoice parameters.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "tasks": { + "description": "Present if the client supports task-augmented requests.", + "properties": { + "cancel": { + "additionalProperties": true, + "description": "Whether this client supports tasks/cancel.", + "properties": {}, + "type": "object" + }, + "list": { + "additionalProperties": true, + "description": "Whether this client supports tasks/list.", + "properties": {}, + "type": "object" + }, + "requests": { + "description": "Specifies which request types can be augmented with tasks.", + "properties": { + "elicitation": { + "description": "Task support for elicitation-related requests.", + "properties": { + "create": { + "additionalProperties": true, + "description": "Whether the client supports task-augmented elicitation/create requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "sampling": { + "description": "Task support for sampling-related requests.", + "properties": { + "createMessage": { + "additionalProperties": true, + "description": "Whether the client supports task-augmented sampling/createMessage requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/InitializedNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/TaskStatusNotification" + }, + { + "$ref": "#/$defs/RootsListChangedNotification" + } + ] + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/$defs/InitializeRequest" + }, + { + "$ref": "#/$defs/PingRequest" + }, + { + "$ref": "#/$defs/ListResourcesRequest" + }, + { + "$ref": "#/$defs/ListResourceTemplatesRequest" + }, + { + "$ref": "#/$defs/ReadResourceRequest" + }, + { + "$ref": "#/$defs/SubscribeRequest" + }, + { + "$ref": "#/$defs/UnsubscribeRequest" + }, + { + "$ref": "#/$defs/ListPromptsRequest" + }, + { + "$ref": "#/$defs/GetPromptRequest" + }, + { + "$ref": "#/$defs/ListToolsRequest" + }, + { + "$ref": "#/$defs/CallToolRequest" + }, + { + "$ref": "#/$defs/GetTaskRequest" + }, + { + "$ref": "#/$defs/GetTaskPayloadRequest" + }, + { + "$ref": "#/$defs/CancelTaskRequest" + }, + { + "$ref": "#/$defs/ListTasksRequest" + }, + { + "$ref": "#/$defs/SetLevelRequest" + }, + { + "$ref": "#/$defs/CompleteRequest" + } + ] + }, + "ClientResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/GetTaskResult", + "description": "The response to a tasks/get request." + }, + { + "$ref": "#/$defs/GetTaskPayloadResult" + }, + { + "$ref": "#/$defs/CancelTaskResult", + "description": "The response to a tasks/cancel request." + }, + { + "$ref": "#/$defs/ListTasksResult" + }, + { + "$ref": "#/$defs/CreateMessageResult" + }, + { + "$ref": "#/$defs/ListRootsResult" + }, + { + "$ref": "#/$defs/ElicitResult" + } + ] + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CompleteRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CompleteRequestParams": { + "description": "Parameters for a `completion/complete` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "context": { + "description": "Additional, optional context for completions", + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Previously-resolved variables in a URI template or prompt.", + "type": "object" + } + }, + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/$defs/PromptReference" + }, + { + "$ref": "#/$defs/ResourceTemplateReference" + } + ] + } + }, + "required": [ + "argument", + "ref" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The server's response to a completion/complete request", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + } + }, + "required": [ + "completion" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ResourceLink" + }, + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CreateMessageRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CreateMessageRequestParams": { + "description": "Parameters for a `sampling/createMessage` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.\nThe client MAY ignore this request.\n\nDefault is \"none\". Values \"thisServer\" and \"allServers\" are soft-deprecated. Servers SHOULD only use these values if the client\ndeclares ClientCapabilities.sampling.context. These values may be removed in future spec releases.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "additionalProperties": true, + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.", + "properties": {}, + "type": "object" + }, + "modelPreferences": { + "$ref": "#/$defs/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + }, + "temperature": { + "type": "number" + }, + "toolChoice": { + "$ref": "#/$defs/ToolChoice", + "description": "Controls how the model uses tools.\nThe client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.\nDefault is `{ mode: \"auto\" }`." + }, + "tools": { + "description": "Tools that the model may use during generation.\nThe client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.", + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The client's response to a sampling/createMessage request from the server.\nThe client should inform the user before returning the sampled message, to allow them\nto inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/$defs/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.\n\nStandard values:\n- \"endTurn\": Natural end of the assistant's turn\n- \"stopSequence\": A stop sequence was encountered\n- \"maxTokens\": Maximum token limit was reached\n- \"toolUse\": The model wants to use one or more tools\n\nThis field is an open string to allow for provider-specific stop reasons.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "CreateTaskResult": { + "description": "A response to a task-augmented request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "task": { + "$ref": "#/$defs/Task" + } + }, + "required": [ + "task" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ElicitRequestFormParams": { + "description": "The parameters for a request to elicit non-sensitive information from the user via a form in the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "message": { + "description": "The message to present to the user describing what information is being requested.", + "type": "string" + }, + "mode": { + "const": "form", + "description": "The elicitation mode.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + }, + "ElicitRequestParams": { + "anyOf": [ + { + "$ref": "#/$defs/ElicitRequestURLParams" + }, + { + "$ref": "#/$defs/ElicitRequestFormParams" + } + ], + "description": "The parameters for a request to elicit additional information from the user via the client." + }, + "ElicitRequestURLParams": { + "description": "The parameters for a request to elicit information from the user via a URL in the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "elicitationId": { + "description": "The ID of the elicitation, which must be unique within the context of the server.\nThe client MUST treat this ID as an opaque value.", + "type": "string" + }, + "message": { + "description": "The message to present to the user explaining why the interaction is needed.", + "type": "string" + }, + "mode": { + "const": "url", + "description": "The elicitation mode.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + }, + "url": { + "description": "The URL that the user should navigate to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The client's response to an elicitation request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "action": { + "description": "The user action in response to the elicitation.\n- \"accept\": User submitted the form/confirmed the action\n- \"decline\": User explicitly decline the action\n- \"cancel\": User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "description": "The submitted form data, only present when action is \"accept\" and mode was \"form\".\nContains values matching the requested schema.\nOmitted for out-of-band mode responses.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "ElicitationCompleteNotification": { + "description": "An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/elicitation/complete", + "type": "string" + }, + "params": { + "properties": { + "elicitationId": { + "description": "The ID of the elicitation that completed.", + "type": "string" + } + }, + "required": [ + "elicitationId" + ], + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/$defs/Result" + }, + "EnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ] + }, + "Error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "$ref": "#/$defs/GetPromptRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetPromptRequestParams": { + "description": "Parameters for a `prompts/get` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The server's response to a prompts/get request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "type": "object" + }, + "GetTaskPayloadRequest": { + "description": "A request to retrieve the result of a completed task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/result", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to retrieve results for.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetTaskPayloadResult": { + "additionalProperties": {}, + "description": "The response to a tasks/result request.\nThe structure matches the result type of the original request.\nFor example, a tools/call task would return the CallToolResult structure.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "GetTaskRequest": { + "description": "A request to retrieve the state of a task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/get", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to query.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetTaskResult": { + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "The response to a tasks/get request." + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD takes steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `light` indicates\nthe icon is designed to be used with a light background, and `dark` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "dark", + "light" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Icons": { + "description": "Base interface to add `icons` property.", + "properties": { + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + } + }, + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the MCP implementation.", + "properties": { + "description": { + "description": "An optional human-readable description of what this implementation does.\n\nThis can be used by clients or servers to provide context about their purpose\nand capabilities. For example, a server might describe the types of resources\nor tools it provides, while a client might describe its intended use case.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "description": "An optional URL of the website for this implementation.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InitializeRequest": { + "description": "This request is sent from the client to the server when it first connects, asking it to begin initialization.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "initialize", + "type": "string" + }, + "params": { + "$ref": "#/$defs/InitializeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "InitializeRequestParams": { + "description": "Parameters for an `initialize` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "capabilities": { + "$ref": "#/$defs/ClientCapabilities" + }, + "clientInfo": { + "$ref": "#/$defs/Implementation" + }, + "protocolVersion": { + "description": "The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.", + "type": "string" + } + }, + "required": [ + "capabilities", + "clientInfo", + "protocolVersion" + ], + "type": "object" + }, + "InitializeResult": { + "description": "After receiving an initialize request from the client, the server sends this response.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "capabilities": { + "$ref": "#/$defs/ServerCapabilities" + }, + "instructions": { + "description": "Instructions describing how to use the server and its features.\n\nThis can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a \"hint\" to the model. For example, this information MAY be added to the system prompt.", + "type": "string" + }, + "protocolVersion": { + "description": "The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/$defs/Implementation" + } + }, + "required": [ + "capabilities", + "protocolVersion", + "serverInfo" + ], + "type": "object" + }, + "InitializedNotification": { + "description": "This notification is sent from the client to the server after initialization has finished.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/initialized", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCErrorResponse": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "$ref": "#/$defs/JSONRPCNotification" + }, + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "A response to a request, containing either the result or error." + }, + "JSONRPCResultResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "LegacyTitledEnumSchema": { + "description": "Use TitledSingleSelectEnumSchema instead.\nThis interface will be removed in a future version.", + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "description": "(Legacy) Display names for enum values.\nNon-standard according to JSON schema 2020-12.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The server's response to a prompts/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/Prompt" + }, + "type": "array" + } + }, + "required": [ + "prompts" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The server's response to a resources/templates/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/$defs/ResourceTemplate" + }, + "type": "array" + } + }, + "required": [ + "resourceTemplates" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The server's response to a resources/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/$defs/Resource" + }, + "type": "array" + } + }, + "required": [ + "resources" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The client's response to a roots/list request from the server.\nThis result contains an array of Root objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "roots": { + "items": { + "$ref": "#/$defs/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListTasksRequest": { + "description": "A request to retrieve a list of tasks.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListTasksResult": { + "description": "The response to a tasks/list request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tasks": { + "items": { + "$ref": "#/$defs/Task" + }, + "type": "array" + } + }, + "required": [ + "tasks" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The server's response to a tools/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "tools" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "$ref": "#/$defs/LoggingMessageNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "LoggingMessageNotificationParams": { + "description": "Parameters for a `notifications/message` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/$defs/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "MultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "NotificationParams": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "NumberSchema": { + "properties": { + "default": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "maximum": { + "type": "integer" + }, + "minimum": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "PaginatedRequestParams": { + "description": "Common parameters for paginated requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + } + }, + "type": "object" + }, + "PingRequest": { + "description": "A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "ping", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/$defs/StringSchema" + }, + { + "$ref": "#/$defs/NumberSchema" + }, + { + "$ref": "#/$defs/BooleanSchema" + }, + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ProgressNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ProgressNotificationParams": { + "description": "Parameters for a `notifications/progress` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/$defs/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to `SamplingMessage`, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "$ref": "#/$defs/ContentBlock" + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ReadResourceRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ReadResourceRequestParams": { + "description": "Parameters for a `resources/read` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The server's response to a resources/read request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "type": "object" + }, + "RelatedTaskMetadata": { + "description": "Metadata for associating messages with a task.\nInclude this in the `_meta` field under the key `io.modelcontextprotocol/related-task`.", + "properties": { + "taskId": { + "description": "The task identifier this message is associated with.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "RequestParams": { + "description": "Common params for any request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ResourceRequestParams": { + "description": "Common parameters when working with resources.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceTemplateReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ResourceUpdatedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ResourceUpdatedNotificationParams": { + "description": "Parameters for a `notifications/resources/updated` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with file:// for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "RootsListChangedNotification": { + "description": "A notification from the client to the server, informing it that the list of roots has changed.\nThis notification should be sent whenever the client adds, removes, or modifies any root.\nThe server should then request an updated list of roots using the ListRootsRequest.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/roots/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "SamplingMessageContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + } + ] + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "additionalProperties": true, + "description": "Present if the server supports argument autocompletion suggestions.", + "properties": {}, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "logging": { + "additionalProperties": true, + "description": "Present if the server supports sending log messages to the client.", + "properties": {}, + "type": "object" + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tasks": { + "description": "Present if the server supports task-augmented requests.", + "properties": { + "cancel": { + "additionalProperties": true, + "description": "Whether this server supports tasks/cancel.", + "properties": {}, + "type": "object" + }, + "list": { + "additionalProperties": true, + "description": "Whether this server supports tasks/list.", + "properties": {}, + "type": "object" + }, + "requests": { + "description": "Specifies which request types can be augmented with tasks.", + "properties": { + "tools": { + "description": "Task support for tool-related requests.", + "properties": { + "call": { + "additionalProperties": true, + "description": "Whether the server supports task-augmented tools/call requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/ResourceListChangedNotification" + }, + { + "$ref": "#/$defs/ResourceUpdatedNotification" + }, + { + "$ref": "#/$defs/PromptListChangedNotification" + }, + { + "$ref": "#/$defs/ToolListChangedNotification" + }, + { + "$ref": "#/$defs/TaskStatusNotification" + }, + { + "$ref": "#/$defs/LoggingMessageNotification" + }, + { + "$ref": "#/$defs/ElicitationCompleteNotification" + } + ] + }, + "ServerRequest": { + "anyOf": [ + { + "$ref": "#/$defs/PingRequest" + }, + { + "$ref": "#/$defs/GetTaskRequest" + }, + { + "$ref": "#/$defs/GetTaskPayloadRequest" + }, + { + "$ref": "#/$defs/CancelTaskRequest" + }, + { + "$ref": "#/$defs/ListTasksRequest" + }, + { + "$ref": "#/$defs/CreateMessageRequest" + }, + { + "$ref": "#/$defs/ListRootsRequest" + }, + { + "$ref": "#/$defs/ElicitRequest" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/InitializeResult" + }, + { + "$ref": "#/$defs/ListResourcesResult" + }, + { + "$ref": "#/$defs/ListResourceTemplatesResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + }, + { + "$ref": "#/$defs/ListPromptsResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + }, + { + "$ref": "#/$defs/ListToolsResult" + }, + { + "$ref": "#/$defs/CallToolResult" + }, + { + "$ref": "#/$defs/GetTaskResult", + "description": "The response to a tasks/get request." + }, + { + "$ref": "#/$defs/GetTaskPayloadResult" + }, + { + "$ref": "#/$defs/CancelTaskResult", + "description": "The response to a tasks/cancel request." + }, + { + "$ref": "#/$defs/ListTasksResult" + }, + { + "$ref": "#/$defs/CompleteResult" + } + ] + }, + "SetLevelRequest": { + "description": "A request from the client to the server, to enable or adjust logging.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "logging/setLevel", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SetLevelRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SetLevelRequestParams": { + "description": "Parameters for a `logging/setLevel` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message." + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "SingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + } + ] + }, + "StringSchema": { + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SubscribeRequest": { + "description": "Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/subscribe", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscribeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscribeRequestParams": { + "description": "Parameters for a `resources/subscribe` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Task": { + "description": "Data associated with a task.", + "properties": { + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval in milliseconds.", + "type": "integer" + }, + "status": { + "$ref": "#/$defs/TaskStatus", + "description": "Current task state." + }, + "statusMessage": { + "description": "Optional human-readable message describing the current task state.\nThis can provide context for any status, including:\n- Reasons for \"cancelled\" status\n- Summaries for \"completed\" status\n- Diagnostic information for \"failed\" status (e.g., error details, what went wrong)", + "type": "string" + }, + "taskId": { + "description": "The task identifier.", + "type": "string" + }, + "ttl": { + "description": "Actual retention duration from creation in milliseconds, null for unlimited.", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "createdAt", + "lastUpdatedAt", + "status", + "taskId", + "ttl" + ], + "type": "object" + }, + "TaskAugmentedRequestParams": { + "description": "Common params for any task-augmented request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "type": "object" + }, + "TaskMetadata": { + "description": "Metadata for augmenting a request with task execution.\nInclude this in the `task` field of the request parameters.", + "properties": { + "ttl": { + "description": "Requested duration in milliseconds to retain task from creation.", + "type": "integer" + } + }, + "type": "object" + }, + "TaskStatus": { + "description": "The status of a task.", + "enum": [ + "cancelled", + "completed", + "failed", + "input_required", + "working" + ], + "type": "string" + }, + "TaskStatusNotification": { + "description": "An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tasks/status", + "type": "string" + }, + "params": { + "$ref": "#/$defs/TaskStatusNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "TaskStatusNotificationParams": { + "allOf": [ + { + "$ref": "#/$defs/NotificationParams" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "Parameters for a `notifications/tasks/status` notification." + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "TitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for array items with enum options and display labels.", + "properties": { + "anyOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The constant enum value.", + "type": "string" + }, + "title": { + "description": "Display title for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "TitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "oneOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The enum value.", + "type": "string" + }, + "title": { + "description": "Display label for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: title, annotations.title, then name." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "execution": { + "$ref": "#/$defs/ToolExecution", + "description": "Execution-related properties for this tool." + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a CallToolResult.\n\nDefaults to JSON Schema 2020-12 when no explicit $schema is provided.\nCurrently restricted to type: \"object\" at the root level.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolChoice": { + "description": "Controls tool selection behavior for sampling requests.", + "properties": { + "mode": { + "description": "Controls the tool use ability of the model:\n- \"auto\": Model decides whether to use tools (default)\n- \"required\": Model MUST use at least one tool before completing\n- \"none\": Model MUST NOT use any tools", + "enum": [ + "auto", + "none", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolExecution": { + "description": "Execution-related properties for a tool.", + "properties": { + "taskSupport": { + "description": "Indicates whether this tool supports task-augmented execution.\nThis allows clients to handle long-running operations through polling\nthe task system.\n\n- \"forbidden\": Tool does not support task-augmented execution (default when absent)\n- \"optional\": Tool may support task-augmented execution\n- \"required\": Tool requires task-augmented execution\n\nDefault: \"forbidden\"", + "enum": [ + "forbidden", + "optional", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ToolResultContent": { + "description": "The result of a tool use, provided by the user back to the assistant.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Optional metadata about the tool result. Clients SHOULD preserve this field when\nincluding tool results in subsequent sampling requests to enable caching optimizations.\n\nSee [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "The unstructured result content of the tool use.\n\nThis has the same format as CallToolResult.content and can include text, images,\naudio, resource links, and embedded resources.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool use resulted in an error.\n\nIf true, the content typically describes the error that occurred.\nDefault: false", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional structured result object.\n\nIf the tool defined an outputSchema, this SHOULD conform to that schema.", + "type": "object" + }, + "toolUseId": { + "description": "The ID of the tool use this result corresponds to.\n\nThis MUST match the ID from a previous ToolUseContent.", + "type": "string" + }, + "type": { + "const": "tool_result", + "type": "string" + } + }, + "required": [ + "content", + "toolUseId", + "type" + ], + "type": "object" + }, + "ToolUseContent": { + "description": "A request from the assistant to call a tool.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Optional metadata about the tool use. Clients SHOULD preserve this field when\nincluding tool uses in subsequent sampling requests to enable caching optimizations.\n\nSee [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "id": { + "description": "A unique identifier for this tool use.\n\nThis ID is used to match tool results to their corresponding tool uses.", + "type": "string" + }, + "input": { + "additionalProperties": {}, + "description": "The arguments to pass to the tool, conforming to the tool's input schema.", + "type": "object" + }, + "name": { + "description": "The name of the tool to call.", + "type": "string" + }, + "type": { + "const": "tool_use", + "type": "string" + } + }, + "required": [ + "id", + "input", + "name", + "type" + ], + "type": "object" + }, + "URLElicitationRequiredError": { + "description": "An error response that indicates that the server requires the client to provide additional information via an elicitation request.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32042, + "type": "integer" + }, + "data": { + "additionalProperties": {}, + "properties": { + "elicitations": { + "items": { + "$ref": "#/$defs/ElicitRequestURLParams" + }, + "type": "array" + } + }, + "required": [ + "elicitations" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "UnsubscribeRequest": { + "description": "Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/unsubscribe", + "type": "string" + }, + "params": { + "$ref": "#/$defs/UnsubscribeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "UnsubscribeRequestParams": { + "description": "Parameters for a `resources/unsubscribe` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "UntitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for the array items.", + "properties": { + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "UntitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + } +} + diff --git a/packages/core/test/corpus/schema-twins/2026-07-28.schema.json b/packages/core/test/corpus/schema-twins/2026-07-28.schema.json new file mode 100644 index 0000000000..5ce9df12e4 --- /dev/null +++ b/packages/core/test/corpus/schema-twins/2026-07-28.schema.json @@ -0,0 +1,3881 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended audience of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/$defs/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BaseMetadata": { + "description": "Base interface for metadata with name (identifier) and title (display name) properties.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "CacheableResult": { + "description": "A result that supports a time-to-live (TTL) hint for client-side caching.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CallToolRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CallToolRequestParams": { + "description": "Parameters for a `tools/call` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "arguments": { + "additionalProperties": {}, + "description": "Arguments to use for the tool call.", + "type": "object" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta", + "name" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The result returned by the server for a {@link CallToolRequesttools/call} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "description": "A list of content objects that represent the unstructured result of the tool call.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "type": "boolean" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "structuredContent": { + "description": "An optional JSON value that represents the structured result of the tool call.\n\nThis can be any JSON value (object, array, string, number, boolean, or null)\nthat conforms to the tool's outputSchema if one is defined." + } + }, + "required": [ + "content", + "resultType" + ], + "type": "object" + }, + "CallToolResultResponse": { + "description": "A successful response from the server for a {@link CallToolRequesttools/call} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/CallToolResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "CancelledNotification": { + "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelledNotificationParams": { + "description": "Parameters for a `notifications/cancelled` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/$defs/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction." + } + }, + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "elicitation": { + "description": "Present if the client supports elicitation from the server.", + "properties": { + "form": { + "$ref": "#/$defs/JSONObject" + }, + "url": { + "$ref": "#/$defs/JSONObject" + } + }, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "extensions": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Optional MCP extensions that the client supports. Keys are extension identifiers\n(e.g., \"io.modelcontextprotocol/oauth-client-credentials\"), and values are\nper-extension settings objects. An empty object indicates support with no settings.\n\nKeys MUST follow the {@link MetaObject`_meta` key naming rules}, with a\nmandatory prefix.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": {}, + "type": "object" + }, + "sampling": { + "description": "Present if the client supports sampling from an LLM.", + "properties": { + "context": { + "$ref": "#/$defs/JSONObject", + "description": "Whether the client supports context inclusion via `includeContext` parameter.\nIf not declared, servers SHOULD only use `includeContext: \"none\"` (or omit it)." + }, + "tools": { + "$ref": "#/$defs/JSONObject", + "description": "Whether the client supports tool use via `tools` and `toolChoice` parameters." + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + } + ] + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/$defs/DiscoverRequest" + }, + { + "$ref": "#/$defs/ListResourcesRequest" + }, + { + "$ref": "#/$defs/ListResourceTemplatesRequest" + }, + { + "$ref": "#/$defs/ReadResourceRequest" + }, + { + "$ref": "#/$defs/SubscriptionsListenRequest" + }, + { + "$ref": "#/$defs/ListPromptsRequest" + }, + { + "$ref": "#/$defs/GetPromptRequest" + }, + { + "$ref": "#/$defs/ListToolsRequest" + }, + { + "$ref": "#/$defs/CallToolRequest" + }, + { + "$ref": "#/$defs/CompleteRequest" + } + ] + }, + "ClientResult": { + "$ref": "#/$defs/Result", + "description": "Common result fields." + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CompleteRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CompleteRequestParams": { + "description": "Parameters for a `completion/complete` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "context": { + "description": "Additional, optional context for completions", + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Previously-resolved variables in a URI template or prompt.", + "type": "object" + } + }, + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/$defs/PromptReference" + }, + { + "$ref": "#/$defs/ResourceTemplateReference" + } + ] + } + }, + "required": [ + "_meta", + "argument", + "ref" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The result returned by the server for a {@link CompleteRequestcompletion/complete} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "completion", + "resultType" + ], + "type": "object" + }, + "CompleteResultResponse": { + "description": "A successful response from the server for a {@link CompleteRequestcompletion/complete} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/CompleteResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ResourceLink" + }, + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CreateMessageRequestParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CreateMessageRequestParams": { + "description": "Parameters for a `sampling/createMessage` request.", + "properties": { + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.\nThe client MAY ignore this request.\n\nDefault is `\"none\"`. The values `\"thisServer\"` and `\"allServers\"` are deprecated (SEP-2596): servers SHOULD\nomit this field or use `\"none\"`, and SHOULD only use the deprecated values if the client declares\n{@link ClientCapabilities.sampling.context}.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/$defs/JSONObject", + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific." + }, + "modelPreferences": { + "$ref": "#/$defs/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "temperature": { + "type": "number" + }, + "toolChoice": { + "$ref": "#/$defs/ToolChoice", + "description": "Controls how the model uses tools.\nThe client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared.\nDefault is `{ mode: \"auto\" }`." + }, + "tools": { + "description": "Tools that the model may use during generation.\nThe client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared.", + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The result returned by the client for a {@link CreateMessageRequestsampling/createMessage} request.\nThe client should inform the user before returning the sampled message, to allow them\nto inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/$defs/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.\n\nStandard values:\n- `\"endTurn\"`: Natural end of the assistant's turn\n- `\"stopSequence\"`: A stop sequence was encountered\n- `\"maxTokens\"`: Maximum token limit was reached\n- `\"toolUse\"`: The model wants to use one or more tools\n\nThis field is an open string to allow for provider-specific stop reasons.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "DiscoverRequest": { + "description": "A request from the client asking the server to advertise its supported\nprotocol versions, capabilities, and other metadata. Servers **MUST**\nimplement `server/discover`. Clients **MAY** call it but are not required\nto — version negotiation can also happen inline via per-request `_meta`.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "server/discover", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "DiscoverResult": { + "description": "The result returned by the server for a {@link DiscoverRequestserver/discover} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "capabilities": { + "$ref": "#/$defs/ServerCapabilities", + "description": "The capabilities of the server." + }, + "instructions": { + "description": "Natural-language guidance describing the server and its features.\n\nThis can be used by clients to improve an LLM's understanding of\navailable tools (e.g., by including it in a system prompt). It should\nfocus on information that helps the model use the server effectively\nand should not duplicate information already in tool descriptions.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Information about the server software implementation." + }, + "supportedVersions": { + "description": "MCP Protocol Versions this server supports. The client should choose a\nversion from this list for use in subsequent requests.", + "items": { + "type": "string" + }, + "type": "array" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "capabilities", + "resultType", + "serverInfo", + "supportedVersions", + "ttlMs" + ], + "type": "object" + }, + "DiscoverResultResponse": { + "description": "A successful response from the server for a {@link DiscoverRequestserver/discover} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/DiscoverResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitRequestParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ElicitRequestFormParams": { + "description": "The parameters for a request to elicit non-sensitive information from the user via a form in the client.", + "properties": { + "message": { + "description": "The message to present to the user describing what information is being requested.", + "type": "string" + }, + "mode": { + "const": "form", + "description": "The elicitation mode.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + }, + "ElicitRequestParams": { + "anyOf": [ + { + "$ref": "#/$defs/ElicitRequestFormParams" + }, + { + "$ref": "#/$defs/ElicitRequestURLParams" + } + ], + "description": "The parameters for a request to elicit additional information from the user via the client." + }, + "ElicitRequestURLParams": { + "description": "The parameters for a request to elicit information from the user via a URL in the client.", + "properties": { + "elicitationId": { + "description": "The ID of the elicitation, which must be unique within the context of the server.\nThe client MUST treat this ID as an opaque value.", + "type": "string" + }, + "message": { + "description": "The message to present to the user explaining why the interaction is needed.", + "type": "string" + }, + "mode": { + "const": "url", + "description": "The elicitation mode.", + "type": "string" + }, + "url": { + "description": "The URL that the user should navigate to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The result returned by the client for an {@link ElicitRequestelicitation/create} request.", + "properties": { + "action": { + "description": "The user action in response to the elicitation.\n- `\"accept\"`: User submitted the form/confirmed the action\n- `\"decline\"`: User explicitly declined the action\n- `\"cancel\"`: User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "description": "The submitted form data, only present when action is `\"accept\"` and mode was `\"form\"`.\nContains values matching the requested schema.\nOmitted for out-of-band mode responses.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "ElicitationCompleteNotification": { + "description": "An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/elicitation/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitationCompleteNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ElicitationCompleteNotificationParams": { + "description": "Parameters for a {@link ElicitationCompleteNotificationnotifications/elicitation/complete} notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "elicitationId": { + "description": "The ID of the elicitation that completed.", + "type": "string" + } + }, + "required": [ + "elicitationId" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/$defs/Result", + "description": "Common result fields." + }, + "EnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ] + }, + "Error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "$ref": "#/$defs/GetPromptRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetPromptRequestParams": { + "description": "Parameters for a `prompts/get` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta", + "name" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The result returned by the server for a {@link GetPromptRequestprompts/get} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "messages", + "resultType" + ], + "type": "object" + }, + "GetPromptResultResponse": { + "description": "A successful response from the server for a {@link GetPromptRequestprompts/get} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD take steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `\"light\"` indicates\nthe icon is designed to be used with a light background, and `\"dark\"` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "dark", + "light" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Icons": { + "description": "Base interface to add `icons` property.", + "properties": { + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + } + }, + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the MCP implementation.", + "properties": { + "description": { + "description": "An optional human-readable description of what this implementation does.\n\nThis can be used by clients or servers to provide context about their purpose\nand capabilities. For example, a server might describe the types of resources\nor tools it provides, while a client might describe its intended use case.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "description": "The version of this implementation.", + "type": "string" + }, + "websiteUrl": { + "description": "An optional URL of the website for this implementation.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InputRequest": { + "anyOf": [ + { + "$ref": "#/$defs/CreateMessageRequest" + }, + { + "$ref": "#/$defs/ListRootsRequest" + }, + { + "$ref": "#/$defs/ElicitRequest" + } + ] + }, + "InputRequests": { + "additionalProperties": { + "$ref": "#/$defs/InputRequest" + }, + "description": "A map of server-initiated requests that the client must fulfill.\nKeys are server-assigned identifiers; values are the request objects.", + "type": "object" + }, + "InputRequiredResult": { + "description": "An InputRequiredResult sent by the server to indicate that additional input is needed\nbefore the request can be completed.\n\nAt least one of `inputRequests` or `requestState` MUST be present.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "inputRequests": { + "$ref": "#/$defs/InputRequests" + }, + "requestState": { + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "InputResponse": { + "anyOf": [ + { + "$ref": "#/$defs/CreateMessageResult" + }, + { + "$ref": "#/$defs/ListRootsResult" + }, + { + "$ref": "#/$defs/ElicitResult" + } + ] + }, + "InputResponseRequestParams": { + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "InputResponses": { + "additionalProperties": { + "$ref": "#/$defs/InputResponse" + }, + "description": "A map of client responses to server-initiated requests.\nKeys correspond to the keys in the {@link InputRequests} map;\nvalues are the client's result for each request.", + "type": "object" + }, + "InternalError": { + "description": "A JSON-RPC error indicating that an internal error occurred on the receiver. This error is returned when the receiver encounters an unexpected condition that prevents it from fulfilling the request.", + "properties": { + "code": { + "const": -32603, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "InvalidParamsError": { + "description": "A JSON-RPC error indicating that the method parameters are invalid or malformed.\n\nIn MCP, this error is returned in various contexts when request parameters fail validation:\n\n- **Tools**: Unknown tool name or invalid tool arguments\n- **Prompts**: Unknown prompt name or missing required arguments\n- **Pagination**: Invalid or expired cursor values\n- **Logging**: Invalid log level\n- **Elicitation**: Server requests an elicitation mode not declared in client capabilities\n- **Sampling**: Missing tool result or tool results mixed with other content", + "properties": { + "code": { + "const": -32602, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "InvalidRequestError": { + "description": "A JSON-RPC error indicating that the request is not a valid request object. This error is returned when the message structure does not conform to the JSON-RPC 2.0 specification requirements for a request (e.g., missing required fields like `jsonrpc` or `method`, or using invalid types for these fields).", + "properties": { + "code": { + "const": -32600, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "JSONArray": { + "items": { + "$ref": "#/$defs/JSONValue" + }, + "type": "array" + }, + "JSONObject": { + "additionalProperties": { + "$ref": "#/$defs/JSONValue" + }, + "type": "object" + }, + "JSONRPCErrorResponse": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "$ref": "#/$defs/JSONRPCNotification" + }, + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "A response to a request, containing either the result or error." + }, + "JSONRPCResultResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "JSONValue": { + "anyOf": [ + { + "$ref": "#/$defs/JSONObject" + }, + { + "items": { + "$ref": "#/$defs/JSONValue" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "LegacyTitledEnumSchema": { + "description": "Use {@link TitledSingleSelectEnumSchema} instead.\nThis interface will be removed in a future version.", + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "description": "(Legacy) Display names for enum values.\nNon-standard according to JSON schema 2020-12.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The result returned by the server for a {@link ListPromptsRequestprompts/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/Prompt" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "prompts", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListPromptsResultResponse": { + "description": "A successful response from the server for a {@link ListPromptsRequestprompts/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListPromptsResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The result returned by the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/$defs/ResourceTemplate" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resourceTemplates", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListResourceTemplatesResultResponse": { + "description": "A successful response from the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListResourceTemplatesResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The result returned by the server for a {@link ListResourcesRequestresources/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/$defs/Resource" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resources", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListResourcesResultResponse": { + "description": "A successful response from the server for a {@link ListResourcesRequestresources/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListResourcesResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The result returned by the client for a {@link ListRootsRequestroots/list} request.\nThis result contains an array of {@link Root} objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "roots": { + "items": { + "$ref": "#/$defs/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The result returned by the server for a {@link ListToolsRequesttools/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resultType", + "tools", + "ttlMs" + ], + "type": "object" + }, + "ListToolsResultResponse": { + "description": "A successful response from the server for a {@link ListToolsRequesttools/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListToolsResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "JSONRPCNotification of a log message passed from server to client. The client opts in by setting `\"io.modelcontextprotocol/logLevel\"` in a request's `_meta`.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "$ref": "#/$defs/LoggingMessageNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "LoggingMessageNotificationParams": { + "description": "Parameters for a `notifications/message` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + }, + "MetaObject": { + "description": "Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions.\n\nCertain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions.\n\nValid keys have two segments:\n\n**Prefix:**\n- Optional — if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`).\n- Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`).\n- Implementations SHOULD use reverse DNS notation (e.g., `com.example/` rather than `example.com/`).\n- Any prefix where the second label is `modelcontextprotocol` or `mcp` is **reserved** for MCP use. For example: `io.modelcontextprotocol/`, `dev.mcp/`, `org.modelcontextprotocol.api/`, and `com.mcp.tools/` are all reserved. However, `com.example.mcp/` is NOT reserved, as the second label is `example`.\n\n**Name:**\n- Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`).\n- Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`).", + "type": "object" + }, + "MethodNotFoundError": { + "description": "A JSON-RPC error indicating that the requested method does not exist or is not available.\n\nIn MCP, a server returns this error when a client invokes a method the server does not implement — either a genuinely unknown method, or one gated behind a server capability the server did not advertise (e.g., calling `prompts/list` when the `prompts` capability was not advertised).\n\nA request that requires a client capability the client did not declare is signalled instead by {@link MissingRequiredClientCapabilityError} (`-32003`).", + "properties": { + "code": { + "const": -32601, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "MissingRequiredClientCapabilityError": { + "description": "Returned when processing a request requires a capability the client did not\ndeclare in `clientCapabilities`. For HTTP, the response status code MUST be\n`400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32003, + "type": "integer" + }, + "data": { + "properties": { + "requiredCapabilities": { + "$ref": "#/$defs/ClientCapabilities", + "description": "The capabilities the server requires from the client to process this request." + } + }, + "required": [ + "requiredCapabilities" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/$defs/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "MultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "NotificationParams": { + "description": "Common params for any notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + } + }, + "type": "object" + }, + "NumberSchema": { + "properties": { + "default": { + "type": "number" + }, + "description": { + "type": "string" + }, + "maximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "PaginatedRequestParams": { + "description": "Common params for paginated requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "ParseError": { + "description": "A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message.", + "properties": { + "code": { + "const": -32700, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/$defs/StringSchema" + }, + { + "$ref": "#/$defs/NumberSchema" + }, + { + "$ref": "#/$defs/BooleanSchema" + }, + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ProgressNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ProgressNotificationParams": { + "description": "Parameters for a {@link ProgressNotificationnotifications/progress} notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/$defs/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to {@link SamplingMessage}, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "$ref": "#/$defs/ContentBlock" + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ReadResourceRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ReadResourceRequestParams": { + "description": "Parameters for a `resources/read` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "requestState": { + "type": "string" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "_meta", + "uri" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The result returned by the server for a {@link ReadResourceRequestresources/read} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "contents", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ReadResourceResultResponse": { + "description": "A successful response from the server for a {@link ReadResourceRequestresources/read} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "RequestMetaObject": { + "description": "Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/$defs/ClientCapabilities", + "description": "The client's capabilities for this specific request. Required.\n\nCapabilities are declared per-request rather than once at initialization;\nan empty object means the client supports no optional capabilities.\nServers MUST NOT infer capabilities from prior requests." + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the client software making the request. Required.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional." + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/$defs/LoggingLevel", + "description": "The desired log level for this request. Optional.\n\nIf absent, the server MUST NOT send any {@link LoggingMessageNotificationnotifications/message}\nnotifications for this request. The client opts in to log messages by\nexplicitly setting a level. Replaces the former `logging/setLevel` RPC." + }, + "io.modelcontextprotocol/protocolVersion": { + "description": "The MCP Protocol Version being used for this request. Required.\n\nFor the HTTP transport, this value MUST match the `MCP-Protocol-Version`\nheader; otherwise the server MUST return a `400 Bad Request`. If the\nserver does not support the requested version, it MUST return an\n{@link UnsupportedProtocolVersionError}.", + "type": "string" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotificationnotifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "required": [ + "io.modelcontextprotocol/clientCapabilities", + "io.modelcontextprotocol/clientInfo", + "io.modelcontextprotocol/protocolVersion" + ], + "type": "object" + }, + "RequestParams": { + "description": "Common params for any request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of {@link ListResourcesRequestresources/list} requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ResourceRequestParams": { + "description": "Common params for resource-related requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "_meta", + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceTemplateReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This is only sent for resources the client opted in to via the `resourceSubscriptions` field of a {@link SubscriptionsListenRequestsubscriptions/listen} request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ResourceUpdatedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ResourceUpdatedNotificationParams": { + "description": "Parameters for a `notifications/resources/updated` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "description": "Common result fields.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "ResultType": { + "description": "Indicates the type of a {@link Result} object, allowing the client to\ndetermine how to parse the response.\n\ncomplete - the request completed successfully and the result contains the final content.\ninput_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request.", + "type": "string" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with `file://` for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "SamplingMessageContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + } + ] + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "$ref": "#/$defs/JSONObject", + "description": "Present if the server supports argument autocompletion suggestions." + }, + "experimental": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "extensions": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Optional MCP extensions that the server supports. Keys are extension identifiers\n(e.g., \"io.modelcontextprotocol/tasks\"), and values are per-extension settings\nobjects. An empty object indicates support with no settings.\n\nKeys MUST follow the {@link MetaObject`_meta` key naming rules}, with a\nmandatory prefix.", + "type": "object" + }, + "logging": { + "$ref": "#/$defs/JSONObject", + "description": "Present if the server supports sending log messages to the client." + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/ResourceListChangedNotification" + }, + { + "$ref": "#/$defs/SubscriptionsAcknowledgedNotification" + }, + { + "$ref": "#/$defs/ResourceUpdatedNotification" + }, + { + "$ref": "#/$defs/PromptListChangedNotification" + }, + { + "$ref": "#/$defs/ToolListChangedNotification" + }, + { + "$ref": "#/$defs/LoggingMessageNotification" + }, + { + "$ref": "#/$defs/ElicitationCompleteNotification" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/DiscoverResult" + }, + { + "$ref": "#/$defs/ListResourcesResult" + }, + { + "$ref": "#/$defs/ListResourceTemplatesResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + }, + { + "$ref": "#/$defs/ListPromptsResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + }, + { + "$ref": "#/$defs/ListToolsResult" + }, + { + "$ref": "#/$defs/CallToolResult" + }, + { + "$ref": "#/$defs/CompleteResult" + } + ] + }, + "SingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + } + ] + }, + "StringSchema": { + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SubscriptionFilter": { + "description": "The set of notification types a client may opt in to on a\n{@link SubscriptionsListenRequestsubscriptions/listen} request.\n\nEach notification type is **opt-in**; the server **MUST NOT** send\nnotification types the client has not explicitly requested here.", + "properties": { + "promptsListChanged": { + "description": "If true, receive {@link PromptListChangedNotificationnotifications/prompts/list_changed}.", + "type": "boolean" + }, + "resourceSubscriptions": { + "description": "Subscribe to {@link ResourceUpdatedNotificationnotifications/resources/updated} for these resource URIs.\nReplaces the former `resources/subscribe` RPC.", + "items": { + "type": "string" + }, + "type": "array" + }, + "resourcesListChanged": { + "description": "If true, receive {@link ResourceListChangedNotificationnotifications/resources/list_changed}.", + "type": "boolean" + }, + "toolsListChanged": { + "description": "If true, receive {@link ToolListChangedNotificationnotifications/tools/list_changed}.", + "type": "boolean" + } + }, + "type": "object" + }, + "SubscriptionsAcknowledgedNotification": { + "description": "Sent by the server as the first message on a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream to acknowledge\nthat the subscription has been established and to report which notification\ntypes it agreed to honor.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/subscriptions/acknowledged", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscriptionsAcknowledgedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscriptionsAcknowledgedNotificationParams": { + "description": "Parameters for a {@link SubscriptionsAcknowledgedNotificationnotifications/subscriptions/acknowledged} notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "notifications": { + "$ref": "#/$defs/SubscriptionFilter", + "description": "The subset of requested notification types the server agreed to honor.\nOnly includes notification types the server actually supports; if the\nclient requested an unsupported type (e.g., `promptsListChanged` when\nthe server has no prompts), it is omitted from this set." + } + }, + "required": [ + "notifications" + ], + "type": "object" + }, + "SubscriptionsListenRequest": { + "description": "Sent from the client to open a long-lived channel for receiving notifications\noutside the context of a specific request. Replaces the previous HTTP GET\nendpoint and ensures consistent behavior between HTTP and STDIO.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "subscriptions/listen", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscriptionsListenRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscriptionsListenRequestParams": { + "description": "Parameters for a {@link SubscriptionsListenRequestsubscriptions/listen} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "notifications": { + "$ref": "#/$defs/SubscriptionFilter", + "description": "The notifications the client opts in to on this stream. The server\n**MUST NOT** send notification types the client has not explicitly\nrequested." + } + }, + "required": [ + "_meta", + "notifications" + ], + "type": "object" + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "TitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for array items with enum options and display labels.", + "properties": { + "anyOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The constant enum value.", + "type": "string" + }, + "title": { + "description": "Display title for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "TitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "oneOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The enum value.", + "type": "string" + }, + "title": { + "description": "Display label for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: `title`, `annotations.title`, then `name`." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "inputSchema": { + "additionalProperties": {}, + "description": "A JSON Schema object defining the expected parameters for the tool.\n\nTool arguments are always JSON objects, so `type: \"object\"` is required at the root.\nBeyond that, any JSON Schema 2020-12 keyword may appear alongside `type` — including\ncomposition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords\n(`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other\nstandard validation or annotation keywords.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "properties": { + "$schema": { + "type": "string" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "additionalProperties": {}, + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a {@link CallToolResult}. This can be any valid JSON Schema 2020-12.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "properties": { + "$schema": { + "type": "string" + } + }, + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a {@link Tool} to clients.\n\nNOTE: all properties in `ToolAnnotations` are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on `ToolAnnotations`\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolChoice": { + "description": "Controls tool selection behavior for sampling requests.", + "properties": { + "mode": { + "description": "Controls the tool use ability of the model:\n- `\"auto\"`: Model decides whether to use tools (default)\n- `\"required\"`: Model MUST use at least one tool before completing\n- `\"none\"`: Model MUST NOT use any tools", + "enum": [ + "auto", + "none", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ToolResultContent": { + "description": "The result of a tool use, provided by the user back to the assistant.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Optional metadata about the tool result. Clients SHOULD preserve this field when\nincluding tool results in subsequent sampling requests to enable caching optimizations." + }, + "content": { + "description": "The unstructured result content of the tool use.\n\nThis has the same format as {@link CallToolResult.content} and can include text, images,\naudio, resource links, and embedded resources.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool use resulted in an error.\n\nIf true, the content typically describes the error that occurred.\nDefault: false", + "type": "boolean" + }, + "structuredContent": { + "description": "An optional structured result value.\n\nThis can be any JSON value (object, array, string, number, boolean, or null).\nIf the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema." + }, + "toolUseId": { + "description": "The ID of the tool use this result corresponds to.\n\nThis MUST match the ID from a previous {@link ToolUseContent}.", + "type": "string" + }, + "type": { + "const": "tool_result", + "type": "string" + } + }, + "required": [ + "content", + "toolUseId", + "type" + ], + "type": "object" + }, + "ToolUseContent": { + "description": "A request from the assistant to call a tool.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Optional metadata about the tool use. Clients SHOULD preserve this field when\nincluding tool uses in subsequent sampling requests to enable caching optimizations." + }, + "id": { + "description": "A unique identifier for this tool use.\n\nThis ID is used to match tool results to their corresponding tool uses.", + "type": "string" + }, + "input": { + "additionalProperties": {}, + "description": "The arguments to pass to the tool, conforming to the tool's input schema.", + "type": "object" + }, + "name": { + "description": "The name of the tool to call.", + "type": "string" + }, + "type": { + "const": "tool_use", + "type": "string" + } + }, + "required": [ + "id", + "input", + "name", + "type" + ], + "type": "object" + }, + "UnsupportedProtocolVersionError": { + "description": "Returned when the request's protocol version is unknown to the server or\nunsupported (e.g., a known experimental or draft version the server has\nchosen not to implement). For HTTP, the response status code MUST be\n`400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32004, + "type": "integer" + }, + "data": { + "properties": { + "requested": { + "description": "The protocol version that was requested by the client.", + "type": "string" + }, + "supported": { + "description": "Protocol versions the server supports. The client should choose a\nmutually supported version from this list and retry.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "requested", + "supported" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "UntitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for the array items.", + "properties": { + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "UntitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + } +} + diff --git a/packages/core/test/corpus/schema-twins/manifest.json b/packages/core/test/corpus/schema-twins/manifest.json new file mode 100644 index 0000000000..1b21d36b3d --- /dev/null +++ b/packages/core/test/corpus/schema-twins/manifest.json @@ -0,0 +1,19 @@ +{ + "comment": "Vendored schema.json twins (TEST-ONLY conformance oracles; never bundled, never runtime). RAW upstream bytes - never reformat: each file is locked to the sha256/bytes below by schemaTwinConformance. Refresh via `pnpm fetch:schema-twins [sha]`, ATOMICALLY with the matching spec.types anchor (see packages/core/src/types/README.md lifecycle rule 4).", + "source": { + "repository": "modelcontextprotocol/modelcontextprotocol", + "commit": "0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65" + }, + "files": { + "2026-07-28": { + "sha256": "afaf886c06dd8d3cbdd556d81b6483b9018112aaf7ee284fa116eca58baf54fc", + "bytes": 172822, + "upstreamPath": "schema/draft/schema.json" + }, + "2025-11-25": { + "sha256": "7b2d96fd95efd2216aa953606b83f5a740ddeaa5ebd3a5d27b45a8296545a118", + "bytes": 174326, + "upstreamPath": "schema/2025-11-25/schema.json" + } + } +} diff --git a/packages/core/test/corpus/specCorpus.test.ts b/packages/core/test/corpus/specCorpus.test.ts index 06fc311ab2..f36586f9e7 100644 --- a/packages/core/test/corpus/specCorpus.test.ts +++ b/packages/core/test/corpus/specCorpus.test.ts @@ -78,8 +78,9 @@ const PENDING_2026: Record = { * parse, so the entry is removed the moment the widening lands. */ const PENDING_2026_FILES: Record = { - 'CallToolResult/result-with-array-structured-content.json': 'array structuredContent (SEP-2549) is not modeled yet', - 'Tool/tool-with-array-output-schema.json': 'array outputSchema (SEP-2549) is not modeled yet' + // (empty — the SEP-2549 array-shape widenings burned when the 2026-era + // wire module landed anchor-exact Tool/CallToolResult forks; the two + // examples are real pins now.) }; type AnyZod = z.ZodType; diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index f488284bd5..309cf6a50a 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -955,6 +955,31 @@ describe('codec-seam hardening in the protocol funnels', () => { await protocol.close(); }); + + test('a synchronous throw out of codec.decodeResult rejects the request instead of escaping into transport.onmessage', async () => { + const [protocolTx, peerTx] = InMemoryTransport.createLinkedPair(); + peerTx.onmessage = message => { + const request = message as JSONRPCRequest; + void peerTx.send({ jsonrpc: '2.0', id: request.id, result: {} }); + }; + await peerTx.start(); + + const protocol = createTestProtocol(); + await protocol.connect(protocolTx); + + // The response callback runs synchronously inside _onresponse; an + // unguarded throw here would propagate into the transport instead of + // failing the request. (The concrete production vector is the 2026 + // codec's method-keyed schema lookup — see the own-key guard in + // rev2026-07-28/codec.ts.) + vi.spyOn(rev2025Codec, 'decodeResult').mockImplementationOnce(() => { + throw new Error('decode exploded'); + }); + + await expect(protocol.request({ method: 'ping' })).rejects.toThrow('decode exploded'); + + await protocol.close(); + }); }); describe('inbound validation precedence: −32601 outranks envelope −32602', () => { diff --git a/packages/core/test/shared/rawResultTypeFirst.test.ts b/packages/core/test/shared/rawResultTypeFirst.test.ts index b9710b6e42..51fb40211f 100644 --- a/packages/core/test/shared/rawResultTypeFirst.test.ts +++ b/packages/core/test/shared/rawResultTypeFirst.test.ts @@ -1,27 +1,35 @@ /** - * Raw-first result discrimination: the client funnel inspects the raw - * `resultType` member BEFORE any schema validation. + * Raw-first result discrimination (V-1) — relocated to its structural home: + * step 1 of the era codec's `decodeResult`, BEFORE any schema validation + * (Q1 increment 2; previously a funnel insertion in `_requestWithSchema`). * - * The hazard this closes: tolerant result schemas (defaults filling absent - * members, loose passthrough) would otherwise mask a non-complete result — - * e.g. an `input_required` body parsing as a successful empty tool result. - * The raw check runs first, so: + * The postures are ERA-SCOPED (Q1-SD3): * - * - `input_required` (or any non-`complete` kind) → typed local error - * carrying the discriminated kind; never a hollow success; no retry. - * - non-string `resultType` → typed invalid-result error (checked raw, - * before any schema could coerce or tolerate it). - * - `'complete'` → the discriminator is consumed (stripped) and the result - * parses as the public shape. - * - absent → untouched (2025-era behavior, byte-identical). + * 2026 era (the connection negotiated '2026-07-28'): + * - `resultType` is REQUIRED. Absent → typed error NAMING the spec + * violation (the absent⇒complete bridge is scoped to earlier-revision + * servers and deliberately NOT extended to modern traffic). + * - `input_required` → discriminated driver payload, surfaced as a typed + * local error until the multi-round-trip driver (M4.1) consumes it. + * - unknown kinds → invalid, no retry. Non-string → invalid. + * - `'complete'` → wire-exact parse (resultType present) then lift. + * + * 2025 era (any legacy version / unbound instance): + * - `resultType` is FOREIGN vocabulary → strip-on-lift (tolerate-and-drop, + * whatever its value); validation then judges the actual content. This is + * a deliberate behavior migration from the era-blind funnel arm (ledgered; + * changeset: codec-split-wire-break). + * + * Either way, the V-1 invariant holds: a non-complete body can NEVER be + * masked into a hollow success by a tolerant result schema. */ import { describe, expect, test } from 'vitest'; import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; import type { BaseContext } from '../../src/shared/protocol.js'; -import { Protocol } from '../../src/shared/protocol.js'; -import { InMemoryTransport } from '../../src/util/inMemory.js'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; import type { JSONRPCRequest } from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; class TestProtocol extends Protocol { protected assertCapabilityForMethod(): void {} @@ -33,7 +41,7 @@ class TestProtocol extends Protocol { } /** Wire a protocol whose peer answers every request with the given raw result body. */ -async function wireWithRawResult(rawResult: unknown): Promise { +async function wireWithRawResult(rawResult: unknown, era?: '2026-07-28'): Promise { const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); serverTx.onmessage = message => { const request = message as JSONRPCRequest; @@ -42,24 +50,27 @@ async function wireWithRawResult(rawResult: unknown): Promise { await serverTx.start(); const protocol = new TestProtocol(); await protocol.connect(clientTx); + if (era) setNegotiatedProtocolVersion(protocol, era); return protocol; } -describe('raw-first resultType discrimination in the request funnel', () => { +const INPUT_REQUIRED_BODY = { + resultType: 'input_required', + inputRequests: { 'elicit-1': { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } }, + requestState: 'opaque' +}; + +async function settle(protocol: TestProtocol): Promise<{ resolved: unknown } | { rejected: unknown }> { + return protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }).then( + result => ({ resolved: result as unknown }), + error => ({ rejected: error as unknown }) + ); +} + +describe('raw-first resultType discrimination — 2026 era (codec decode step 1)', () => { test('an input_required body surfaces the discriminated kind, never an empty-content success', async () => { - // The exact masking hazard: tools/call's result schema defaults - // content to [] — without the raw-first check this body would - // resolve as { content: [] }. - const protocol = await wireWithRawResult({ - resultType: 'input_required', - inputRequests: { 'elicit-1': { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } }, - requestState: 'opaque' - }); - - const outcome = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }).then( - result => ({ resolved: result as unknown }), - error => ({ rejected: error as unknown }) - ); + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY, '2026-07-28'); + const outcome = await settle(protocol); expect('resolved' in outcome, 'must not resolve as a success').toBe(false); const rejection = (outcome as { rejected: unknown }).rejected; @@ -72,44 +83,49 @@ describe('raw-first resultType discrimination in the request funnel', () => { }); test('an unrecognized resultType kind is invalid — surfaced, no retry', async () => { - const protocol = await wireWithRawResult({ resultType: 'mystery-kind', content: [] }); + const protocol = await wireWithRawResult({ resultType: 'mystery-kind', content: [] }, '2026-07-28'); + const outcome = await settle(protocol); - const rejection = await protocol - .request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }) - .catch((error: unknown) => error); + expect('rejected' in outcome).toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected as SdkError; expect(rejection).toBeInstanceOf(SdkError); - expect((rejection as SdkError).code).toBe(SdkErrorCode.UnsupportedResultType); - expect((rejection as SdkError).data).toMatchObject({ resultType: 'mystery-kind' }); + expect(rejection.code).toBe(SdkErrorCode.UnsupportedResultType); + expect(rejection.data).toMatchObject({ resultType: 'mystery-kind' }); await protocol.close(); }); - test('a non-string resultType can never surface as a success (rejected in the funnel)', async () => { - // Pre-codec-split, a non-string resultType died at JSON-RPC envelope - // classification because the SHARED wire schema typed the member as - // an optional string. With resultType cut from the neutral schemas - // (Q1 increment 2 — the masking surface is gone), the loose envelope - // passes the foreign key through and the funnel's defensive raw-type - // arm rejects it IN-BAND with a typed error. Either way it can never - // be masked into a success — which is the V-1 invariant this test - // exists to pin. - const protocol = await wireWithRawResult({ resultType: 42, content: [] }); - - const rejection = await protocol - .request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }) - .catch((error: unknown) => error); + test('ABSENT resultType is a spec violation on the modern leg — typed error naming it (Q1-SD3 i)', async () => { + // The absent⇒complete bridge is scoped to earlier-revision servers; + // a 2026-negotiated peer that omits the REQUIRED member is broken. + const protocol = await wireWithRawResult({ content: [{ type: 'text', text: 'looks fine' }] }, '2026-07-28'); + const outcome = await settle(protocol); + + expect('rejected' in outcome).toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected as SdkError; expect(rejection).toBeInstanceOf(SdkError); - expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); - expect((rejection as SdkError).data).toMatchObject({ resultType: 42 }); + expect(rejection.code).toBe(SdkErrorCode.InvalidResult); + expect(rejection.message).toContain('missing required resultType'); + expect(rejection.data).toMatchObject({ method: 'tools/call', violation: 'missing-resultType' }); + + await protocol.close(); + }); + + test('a non-string resultType can never surface as a success', async () => { + const protocol = await wireWithRawResult({ resultType: 42, content: [] }, '2026-07-28'); + const outcome = await settle(protocol); + + expect('rejected' in outcome).toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected as SdkError; + expect(rejection).toBeInstanceOf(SdkError); + expect(rejection.code).toBe(SdkErrorCode.InvalidResult); + expect(rejection.data).toMatchObject({ resultType: 42 }); await protocol.close(); }); test("resultType 'complete' is consumed: the result resolves without the wire member", async () => { - const protocol = await wireWithRawResult({ - resultType: 'complete', - content: [{ type: 'text', text: 'done' }] - }); + const protocol = await wireWithRawResult({ resultType: 'complete', content: [{ type: 'text', text: 'done' }] }, '2026-07-28'); const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); expect(result.content).toEqual([{ type: 'text', text: 'done' }]); @@ -117,25 +133,84 @@ describe('raw-first resultType discrimination in the request funnel', () => { await protocol.close(); }); +}); + +describe('raw-first resultType handling — 2025 era (strip-on-lift, Q1-SD3 ii)', () => { + test('a foreign input_required body is stripped, then validation judges the content — never a silent success', async () => { + // BEHAVIOR MIGRATION (ledgered): pre-split, the era-blind funnel arm + // rejected this with UnsupportedResultType on every leg. On the 2025 + // era resultType carries no meaning — the ruled posture strips the + // foreign key and lets validation decide. The body has no content, + // so it fails the (default-free) tools/call result schema LOUDLY — + // the V-1 invariant (never a hollow success) holds. + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + const outcome = await settle(protocol); + + expect('resolved' in outcome, 'must not resolve as a success').toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected as SdkError; + expect(rejection).toBeInstanceOf(SdkError); + expect(rejection.code).toBe(SdkErrorCode.InvalidResult); + + await protocol.close(); + }); + + test('strip-on-lift is VALUE-BLIND: a foreign input_required WITH a valid body resolves, member stripped', async () => { + // The strip keys on the member's PRESENCE, never its value — even the + // driver kind is foreign vocabulary on this era. With a valid body + // the request resolves; the stripped key never surfaces. (The + // sibling test above covers the invalid-body arm: there the strip + // also runs, and validation then rejects on the actual content.) + const protocol = await wireWithRawResult({ resultType: 'input_required', content: [{ type: 'text', text: 'ok' }] }); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]); + expect('resultType' in result).toBe(false); + + await protocol.close(); + }); + + test('a foreign non-string resultType is stripped; an otherwise-valid result resolves without it', async () => { + const protocol = await wireWithRawResult({ resultType: 42, content: [{ type: 'text', text: 'ok' }] }); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]); + expect('resultType' in result).toBe(false); + + await protocol.close(); + }); test("resultType 'complete' on a strict empty result still parses (stripped before validation)", async () => { const protocol = await wireWithRawResult({ resultType: 'complete' }); - // EmptyResultSchema is strict; the discriminator is consumed before - // validation, so the 2026-era ack parses as the public empty result. const result = await protocol.request({ method: 'ping' }); expect(result).toEqual({}); await protocol.close(); }); - test('absent resultType is untouched 2025-era behavior', async () => { - const protocol = await wireWithRawResult({ content: [{ type: 'text', text: 'plain' }], extra: 'kept' }); + test('absent resultType is untouched 2025-era behavior (siblings kept)', async () => { + const protocol = await wireWithRawResult({ content: [{ type: 'text', text: 'plain' }], extraSibling: 'kept' }); const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); expect(result.content).toEqual([{ type: 'text', text: 'plain' }]); - expect((result as Record).extra).toBe('kept'); + expect((result as Record).extraSibling).toBe('kept'); await protocol.close(); }); }); + +describe('decode step 2 — the wire-exact schema lookup is own-key only', () => { + test("a prototype-chain method name (e.g. 'constructor') skips the wire-exact parse instead of throwing", async () => { + const { rev2026Codec } = await import('../../src/wire/rev2026-07-28/codec.js'); + // A bare object-prototype hit would surface Function (not a schema) + // and throw a TypeError out of the decode hop. The lookup must treat + // non-own keys exactly like unknown methods: no wire-exact parse, + // straight to the lift. + const decoded = rev2026Codec.decodeResult('constructor', { resultType: 'complete', anything: 'kept' }); + expect(decoded.kind).toBe('complete'); + if (decoded.kind === 'complete') { + expect((decoded.result as Record).anything).toBe('kept'); + expect('resultType' in decoded.result).toBe(false); + } + }); +}); diff --git a/packages/core/test/spec.types.2025-11-25.test.ts b/packages/core/test/spec.types.2025-11-25.test.ts index 40b14a43cf..76d6d7bfab 100644 --- a/packages/core/test/spec.types.2025-11-25.test.ts +++ b/packages/core/test/spec.types.2025-11-25.test.ts @@ -1,13 +1,16 @@ /** - * Compares the SDK's types against the frozen 2025-11-25 release schema - * (spec.types.2025-11-25.ts). The 2026-07-28 comparison lives in + * Per-revision parity against the FROZEN 2025-11-25 release schema + * (spec.types.2025-11-25.ts). The draft comparison lives in * spec.types.2026-07-28.test.ts. * - * This contains: - * - Static type checks to verify the Spec's types are compatible with the SDK's types - * (mutually assignable — no type-level workarounds should be needed) - * - Runtime checks to verify each Spec type has a static check - * (note: a few don't have SDK types, see MISSING_SDK_TYPES below) + * Q1 increment 2 retired the 20 `@ts-expect-error` affordances this file + * used to carry: where the neutral public types deliberately follow the + * 2026-07-28 typing (the shared-tier adjudications), the comparisons now + * target the 2025-era WIRE-VIEW types (`wire/rev2025-11-25/wireTypes.ts`), + * which restate the anchor shape exactly and document each adjudication in + * one place. Zero affordances remain: every check below is exact, both + * directions, and the key-parity pins include the previously-suppressed + * names (PromptArgument/PromptReference `title`, the capabilities key sets). */ import fs from 'node:fs'; import path from 'node:path'; @@ -19,6 +22,21 @@ import type * as SDKTypes from '../src/types/index.js'; // Role-union comparisons against this FROZEN revision's anchor therefore // target the wire-era artifacts. import type * as Wire2025 from '../src/wire/rev2025-11-25/schemas.js'; +import type { + Wire2025ClientCapabilities, + Wire2025ClientRequestView, + Wire2025CreateMessageRequest, + Wire2025CreateMessageRequestParams, + Wire2025InitializeRequest, + Wire2025InitializeRequestParams, + Wire2025InitializeResult, + Wire2025ListToolsResult, + Wire2025PromptArgument, + Wire2025PromptReference, + Wire2025ServerCapabilities, + Wire2025ServerRequestView, + Wire2025Tool +} from '../src/wire/rev2025-11-25/wireTypes.js'; import type * as z4 from 'zod/v4'; type Wire2025ClientRequest = z4.infer; @@ -49,8 +67,7 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - InitializeRequestParams: (sdk: SDKTypes.InitializeRequestParams, spec: SpecTypes.InitializeRequestParams) => { - // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object`; the SDK follows the 2026-07-28 schema's JSONObject + InitializeRequestParams: (sdk: Wire2025InitializeRequestParams, spec: SpecTypes.InitializeRequestParams) => { sdk = spec; spec = sdk; }, @@ -100,10 +117,8 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CreateMessageRequestParams: (sdk: SDKTypes.CreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { - // @ts-expect-error 2025-11-25 types `metadata` as `object`; the SDK follows the 2026-07-28 schema's JSONObject + CreateMessageRequestParams: (sdk: Wire2025CreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { sdk = spec; - // @ts-expect-error the SDK's JSONValue-typed tool inputSchema properties are not assignable to 2025-11-25's `object` spec = sdk; }, CompleteRequestParams: (sdk: SDKTypes.CompleteRequestParams, spec: SpecTypes.CompleteRequestParams) => { @@ -257,20 +272,16 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - Tool: (sdk: SDKTypes.Tool, spec: SpecTypes.Tool) => { - // @ts-expect-error 2025-11-25 types inputSchema/outputSchema properties as `object`; the SDK follows the 2026-07-28 schema's JSONValue + Tool: (sdk: Wire2025Tool, spec: SpecTypes.Tool) => { sdk = spec; - // @ts-expect-error the SDK's JSONValue-typed inputSchema/outputSchema properties are not assignable to 2025-11-25's `object` spec = sdk; }, ListToolsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest) => { sdk = spec; spec = sdk; }, - ListToolsResult: (sdk: SDKTypes.ListToolsResult, spec: SpecTypes.ListToolsResult) => { - // @ts-expect-error 2025-11-25 vs 2026-07-28 Tool typing; see the Tool check above + ListToolsResult: (sdk: Wire2025ListToolsResult, spec: SpecTypes.ListToolsResult) => { sdk = spec; - // @ts-expect-error 2025-11-25 vs 2026-07-28 Tool typing; see the Tool check above spec = sdk; }, CallToolResult: (sdk: SDKTypes.CallToolResult, spec: SpecTypes.CallToolResult) => { @@ -489,41 +500,32 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CreateMessageRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CreateMessageRequest) => { - // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of params metadata/tools; see the CreateMessageRequestParams check above + CreateMessageRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CreateMessageRequest) => { sdk = spec; - // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of params metadata/tools; see the CreateMessageRequestParams check above spec = sdk; }, - InitializeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.InitializeRequest) => { - // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object`; the SDK follows the 2026-07-28 schema's JSONObject + InitializeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.InitializeRequest) => { sdk = spec; spec = sdk; }, - InitializeResult: (sdk: SDKTypes.InitializeResult, spec: SpecTypes.InitializeResult) => { - // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object`; the SDK follows the 2026-07-28 schema's JSONObject + InitializeResult: (sdk: Wire2025InitializeResult, spec: SpecTypes.InitializeResult) => { sdk = spec; spec = sdk; }, - ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: SpecTypes.ClientCapabilities) => { - // @ts-expect-error 2025-11-25 types experimental/sampling/elicitation/tasks blobs as `object`; the SDK follows the 2026-07-28 schema's JSONObject + ClientCapabilities: (sdk: Wire2025ClientCapabilities, spec: SpecTypes.ClientCapabilities) => { sdk = spec; spec = sdk; }, - ServerCapabilities: (sdk: SDKTypes.ServerCapabilities, spec: SpecTypes.ServerCapabilities) => { - // @ts-expect-error 2025-11-25 types experimental/logging/completions/tasks blobs as `object`; the SDK follows the 2026-07-28 schema's JSONObject + ServerCapabilities: (sdk: Wire2025ServerCapabilities, spec: SpecTypes.ServerCapabilities) => { sdk = spec; spec = sdk; }, - ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { - // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object` (via the InitializeRequest member); the SDK follows the 2026-07-28 schema's JSONObject + ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { sdk = spec; spec = sdk; }, - ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { - // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of CreateMessageRequest params; see the CreateMessageRequestParams check above + ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { sdk = spec; - // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of CreateMessageRequest params; see the CreateMessageRequestParams check above spec = sdk; }, LoggingMessageNotification: (sdk: WithJSONRPC, spec: SpecTypes.LoggingMessageNotification) => { @@ -727,8 +729,7 @@ type _K_JSONRPCNotification = Assert>; type _K_Notification = Assert>; type _K_ResourceTemplateReference = Assert>; -// @ts-expect-error Genuine mismatch: SDK PromptReference is missing 'title' from spec -type _K_PromptReference = Assert>; +type _K_PromptReference = Assert>; type _K_ToolAnnotations = Assert>; type _K_Tool = Assert>; type _K_ListToolsResult = Assert>; @@ -740,8 +741,7 @@ type _K_ResourceContents = Assert>; type _K_BlobResourceContents = Assert>; type _K_Resource = Assert>; -// @ts-expect-error Genuine mismatch: SDK PromptArgument is missing 'title' from spec -type _K_PromptArgument = Assert>; +type _K_PromptArgument = Assert>; type _K_Prompt = Assert>; type _K_ListPromptsResult = Assert>; type _K_GetPromptResult = Assert>; @@ -768,10 +768,8 @@ type _K_LegacyTitledEnumSchema = Assert>; type _K_JSONRPCResultResponse = Assert>; type _K_InitializeResult = Assert>; -// @ts-expect-error SDK follows the 2026-07-28 schema's `extensions` capability key; not in released 2025-11-25 -type _K_ClientCapabilities = Assert>; -// @ts-expect-error SDK follows the 2026-07-28 schema's `extensions` capability key; not in released 2025-11-25 -type _K_ServerCapabilities = Assert>; +type _K_ClientCapabilities = Assert>; +type _K_ServerCapabilities = Assert>; type _K_SamplingMessage = Assert>; type _K_Icon = Assert>; type _K_Icons = Assert>; diff --git a/packages/core/test/spec.types.2026-07-28.test.ts b/packages/core/test/spec.types.2026-07-28.test.ts index 74f6d77750..5bf80604c6 100644 --- a/packages/core/test/spec.types.2026-07-28.test.ts +++ b/packages/core/test/spec.types.2026-07-28.test.ts @@ -1,15 +1,20 @@ /** - * Compares the SDK's types against the upcoming 2026-07-28 schema (spec.types.2026-07-28.ts). - * The frozen-release comparison lives in spec.types.2025-11-25.test.ts. + * Per-revision parity: the 2026-era WIRE artifacts against the 2026-07-28 + * anchor (spec.types.2026-07-28.ts). The frozen-release comparison lives in + * spec.types.2025-11-25.test.ts. * - * The SDK does not implement the 2026-07-28 surface yet: every 2026-07-28 type whose shape the SDK - * does not (yet) match is listed in MISSING_SDK_TYPES_2026_07_28 below. Removing a name from - * that list forces a real mutual-assignability check to be added to sdkTypeChecks (the - * completeness tests below fail otherwise) — implementation work burns the list down. + * Q1 increment 2 retired the old 67-name burn-down list (whose "permanent + * stratum" could never burn under a single shared schema set): the SDK now + * models era-specific wire shapes in `wire/rev2026-07-28/`, and everything + * that module models is compared here EXACTLY — wire-true request views + * (envelope-required `_meta`), resultType-required result wrappers, the + * forked Tool/SamplingMessage payloads, response envelopes, and discover. * - * Unlike MISSING_SDK_TYPES in the 2025-11-25 comparison, names in this list may well - * exist in the SDK (e.g. RequestParams) — they are listed because the 2026-07-28 revision changed - * their shape, not necessarily because the SDK lacks them. + * What remains unmodeled lives in FEATURE_OWNED_PENDING_2026 below: every + * entry is OWNED by a named feature issue and is stale-checked — adding a + * check for a pending name forces the entry's removal, and the completeness + * tests fail on any spec type that is neither checked nor owned. There is no + * permanent stratum: when the owning features land, the list reaches zero. */ import fs from 'node:fs'; import path from 'node:path'; @@ -21,6 +26,8 @@ import { } from '../src/types/spec.types.2026-07-28.js'; import type * as SpecTypes from '../src/types/spec.types.2026-07-28.js'; import type * as SDKTypes from '../src/types/index.js'; +import type * as Wire2026 from '../src/wire/rev2026-07-28/schemas.js'; +import type * as z4 from 'zod/v4'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -37,6 +44,40 @@ type WithJSONRPC = T & { jsonrpc: '2.0' }; // Adds the `jsonrpc` and `id` properties to a type, to match the on-wire format of requests. type WithJSONRPCRequest = T & { jsonrpc: '2.0'; id: SDKTypes.RequestId }; +/* The 2026-era wire artifacts under comparison (inferred from the era module's + * Zod schemas — the same objects the codec parses with). */ +type WResult = z4.infer; +type WResultType = z4.infer; +type WPaginatedResult = z4.infer; +type WCacheableResult = z4.infer; +type WCallToolResult = z4.infer; +type WCompleteResult = z4.infer; +type WGetPromptResult = z4.infer; +type WListPromptsResult = z4.infer; +type WListResourceTemplatesResult = z4.infer; +type WListResourcesResult = z4.infer; +type WListToolsResult = z4.infer; +type WReadResourceResult = z4.infer; +type WDiscoverResult = z4.infer; +type WTool = z4.infer; +type WSamplingMessage = z4.infer; +type WJSONRPCResultResponse = z4.infer; +type WCompleteRequest = z4.infer; +type WListPromptsRequest = z4.infer; +type WListResourceTemplatesRequest = z4.infer; +type WListResourcesRequest = z4.infer; +type WListToolsRequest = z4.infer; +type WReadResourceRequest = z4.infer; +type WDiscoverRequest = z4.infer; +// Param/base shapes derived from the request views (no second source of truth): +type WRequestParams = NonNullable; +type WPaginatedRequestParams = WListToolsRequest['params']; +type WResourceRequestParams = WReadResourceRequest['params']; +type WCompleteRequestParams = WCompleteRequest['params']; +// PaginatedRequest in the anchor keeps `method: string` (it is the base, not +// a concrete method) — composed from the derived params shape. +type WPaginatedRequest = WithJSONRPCRequest<{ method: string; params: WPaginatedRequestParams }>; + const sdkTypeChecks = { JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { sdk = spec; @@ -378,126 +419,245 @@ const sdkTypeChecks = { } }; +/* 2026-era wire parity checks (Q1 increment 2) — appended to sdkTypeChecks. */ +const wireParityChecks = { + Result: (sdk: WResult, spec: SpecTypes.Result) => { + sdk = spec; + spec = sdk; + }, + ResultType: (sdk: WResultType, spec: SpecTypes.ResultType) => { + sdk = spec; + spec = sdk; + }, + EmptyResult: (sdk: WResult, spec: SpecTypes.EmptyResult) => { + sdk = spec; + spec = sdk; + }, + ClientResult: (sdk: WResult, spec: SpecTypes.ClientResult) => { + sdk = spec; + spec = sdk; + }, + PaginatedResult: (sdk: WPaginatedResult, spec: SpecTypes.PaginatedResult) => { + sdk = spec; + spec = sdk; + }, + CacheableResult: (sdk: WCacheableResult, spec: SpecTypes.CacheableResult) => { + sdk = spec; + spec = sdk; + }, + CallToolResult: (sdk: WCallToolResult, spec: SpecTypes.CallToolResult) => { + sdk = spec; + spec = sdk; + }, + CompleteResult: (sdk: WCompleteResult, spec: SpecTypes.CompleteResult) => { + sdk = spec; + spec = sdk; + }, + GetPromptResult: (sdk: WGetPromptResult, spec: SpecTypes.GetPromptResult) => { + sdk = spec; + spec = sdk; + }, + ListPromptsResult: (sdk: WListPromptsResult, spec: SpecTypes.ListPromptsResult) => { + sdk = spec; + spec = sdk; + }, + ListResourceTemplatesResult: (sdk: WListResourceTemplatesResult, spec: SpecTypes.ListResourceTemplatesResult) => { + sdk = spec; + spec = sdk; + }, + ListResourcesResult: (sdk: WListResourcesResult, spec: SpecTypes.ListResourcesResult) => { + sdk = spec; + spec = sdk; + }, + ListToolsResult: (sdk: WListToolsResult, spec: SpecTypes.ListToolsResult) => { + sdk = spec; + spec = sdk; + }, + ReadResourceResult: (sdk: WReadResourceResult, spec: SpecTypes.ReadResourceResult) => { + sdk = spec; + spec = sdk; + }, + DiscoverResult: (sdk: WDiscoverResult, spec: SpecTypes.DiscoverResult) => { + sdk = spec; + spec = sdk; + }, + DiscoverRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.DiscoverRequest) => { + sdk = spec; + spec = sdk; + }, + Tool: (sdk: WTool, spec: SpecTypes.Tool) => { + sdk = spec; + spec = sdk; + }, + SamplingMessage: (sdk: WSamplingMessage, spec: SpecTypes.SamplingMessage) => { + sdk = spec; + spec = sdk; + }, + SamplingMessageContentBlock: ( + sdk: z4.infer, + spec: SpecTypes.SamplingMessageContentBlock + ) => { + sdk = spec; + spec = sdk; + }, + ToolResultContent: (sdk: z4.infer, spec: SpecTypes.ToolResultContent) => { + sdk = spec; + spec = sdk; + }, + Notification: (sdk: SDKTypes.Notification, spec: SpecTypes.Notification) => { + sdk = spec; + spec = sdk; + }, + JSONRPCResultResponse: (sdk: WJSONRPCResultResponse, spec: SpecTypes.JSONRPCResultResponse) => { + sdk = spec; + spec = sdk; + }, + JSONRPCResponse: (sdk: WJSONRPCResultResponse | SDKTypes.JSONRPCErrorResponse, spec: SpecTypes.JSONRPCResponse) => { + sdk = spec; + spec = sdk; + }, + JSONRPCMessage: ( + sdk: SDKTypes.JSONRPCRequest | WithJSONRPC | WJSONRPCResultResponse | SDKTypes.JSONRPCErrorResponse, + spec: SpecTypes.JSONRPCMessage + ) => { + sdk = spec; + spec = sdk; + }, + CompleteResultResponse: (sdk: z4.infer, spec: SpecTypes.CompleteResultResponse) => { + sdk = spec; + spec = sdk; + }, + ListPromptsResultResponse: ( + sdk: z4.infer, + spec: SpecTypes.ListPromptsResultResponse + ) => { + sdk = spec; + spec = sdk; + }, + ListResourceTemplatesResultResponse: ( + sdk: z4.infer, + spec: SpecTypes.ListResourceTemplatesResultResponse + ) => { + sdk = spec; + spec = sdk; + }, + ListResourcesResultResponse: ( + sdk: z4.infer, + spec: SpecTypes.ListResourcesResultResponse + ) => { + sdk = spec; + spec = sdk; + }, + ListToolsResultResponse: (sdk: z4.infer, spec: SpecTypes.ListToolsResultResponse) => { + sdk = spec; + spec = sdk; + }, + DiscoverResultResponse: (sdk: z4.infer, spec: SpecTypes.DiscoverResultResponse) => { + sdk = spec; + spec = sdk; + }, + RequestParams: (sdk: WRequestParams, spec: SpecTypes.RequestParams) => { + sdk = spec; + spec = sdk; + }, + PaginatedRequestParams: (sdk: WPaginatedRequestParams, spec: SpecTypes.PaginatedRequestParams) => { + sdk = spec; + spec = sdk; + }, + ResourceRequestParams: (sdk: WResourceRequestParams, spec: SpecTypes.ResourceRequestParams) => { + sdk = spec; + spec = sdk; + }, + CompleteRequestParams: (sdk: WCompleteRequestParams, spec: SpecTypes.CompleteRequestParams) => { + sdk = spec; + spec = sdk; + }, + PaginatedRequest: (sdk: WPaginatedRequest, spec: SpecTypes.PaginatedRequest) => { + sdk = spec; + spec = sdk; + }, + CompleteRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CompleteRequest) => { + sdk = spec; + spec = sdk; + }, + ListPromptsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListPromptsRequest) => { + sdk = spec; + spec = sdk; + }, + ListResourceTemplatesRequest: ( + sdk: WithJSONRPCRequest, + spec: SpecTypes.ListResourceTemplatesRequest + ) => { + sdk = spec; + spec = sdk; + }, + ListResourcesRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListResourcesRequest) => { + sdk = spec; + spec = sdk; + }, + ListToolsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest) => { + sdk = spec; + spec = sdk; + } +}; + +const allTypeChecks = { ...sdkTypeChecks, ...wireParityChecks }; + // Generated from the 2026-07-28 schema by `pnpm run fetch:spec-types 2026-07-28 `. const SPEC_TYPES_FILE = path.resolve(__dirname, '../src/types/spec.types.2026-07-28.ts'); /** - * 2026-07-28 spec types the SDK does not match yet. Spec-implementation work for the - * 2026-07-28 release removes entries from this list as the SDK adopts each shape. + * Spec types the 2026-era wire module does not model yet — every entry is + * OWNED by a named feature issue (no permanent stratum; the list reaches + * zero as the owners land). Adding a parity check for one of these names + * forces the entry's removal (stale-check below). */ -const MISSING_SDK_TYPES_2026_07_28 = [ +const FEATURE_OWNED_PENDING_2026: Record = { // Inlined in the SDK (same as the 2025-11-25 comparison): - 'Error', // The inner error object of a JSONRPCError - - // SEP-2575 per-request envelope: 2026-07-28 requests REQUIRE a `_meta` envelope - // (`io.modelcontextprotocol/protocolVersion`, clientInfo, clientCapabilities). The - // envelope itself is modeled by RequestMetaEnvelope (see sdkTypeChecks above); the - // request shapes below stay here because the SDK wire schemas deliberately keep - // `_meta` lenient — the same schemas parse pre-2026 requests (no envelope) and 2026 - // requests, with envelope requiredness enforced per request at dispatch. They burn - // only if the SDK ever models era-specific request types. - 'RequestParams', - 'PaginatedRequestParams', - 'ResourceRequestParams', - 'CallToolRequestParams', - 'CompleteRequestParams', - 'GetPromptRequestParams', - 'ReadResourceRequestParams', - 'CreateMessageRequestParams', - 'PaginatedRequest', - 'CallToolRequest', - 'CompleteRequest', - 'GetPromptRequest', - 'ListPromptsRequest', - 'ListResourceTemplatesRequest', - 'ListResourcesRequest', - 'ListRootsRequest', - 'ListToolsRequest', - 'ReadResourceRequest', - 'CreateMessageRequest', - 'ClientRequest', - - // SEP-2322 (MRTR) → PR for MRTR: 2026-07-28 results carry a required `resultType` - // discriminator. The SDK base result schema carries `resultType` as an optional - // passthrough only (absent means "complete"); per-result modeling lands with MRTR. - 'Result', - 'EmptyResult', - 'PaginatedResult', - 'CallToolResult', - 'CompleteResult', - 'ElicitResult', - 'GetPromptResult', - 'ListPromptsResult', - 'ListResourceTemplatesResult', - 'ListResourcesResult', - 'ListRootsResult', - 'ListToolsResult', - 'ReadResourceResult', - 'CreateMessageResult', - 'ClientResult', - 'ServerResult', - 'ResultType', - - // SEP-2549 cacheable results: `ttlMs`/`cacheScope` caching hints on the list/read - // result shapes → PR for SEP-2549: - 'CacheableResult', + Error: 'the inner error object of a JSONRPCError is inlined in the SDK', - // Response envelopes embedding the changed Result shape → PR for MRTR: - 'JSONRPCResultResponse', - 'JSONRPCResponse', - 'JSONRPCMessage', - 'CallToolResultResponse', - 'CompleteResultResponse', - 'GetPromptResultResponse', - 'ListPromptsResultResponse', - 'ListResourceTemplatesResultResponse', - 'ListResourcesResultResponse', - 'ListToolsResultResponse', - 'ReadResourceResultResponse', + // M4.1 MRTR (#13): the in-band input-request surface and the demoted + // sampling/elicitation/roots shapes (wire requests in 2025, in-band + // InputRequest payloads in 2026 — the SDK models them when the + // multi-round-trip driver lands): + InputRequest: 'M4.1 MRTR (#13)', + InputRequests: 'M4.1 MRTR (#13)', + InputRequiredResult: 'M4.1 MRTR (#13)', + InputResponse: 'M4.1 MRTR (#13)', + InputResponseRequestParams: 'M4.1 MRTR (#13)', + InputResponses: 'M4.1 MRTR (#13)', + CreateMessageRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', + CreateMessageRequestParams: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', + CreateMessageResult: 'M4.1 MRTR (#13) — in-band response shape', + ElicitResult: 'M4.1 MRTR (#13) — in-band response shape', + ListRootsRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', + ListRootsResult: 'M4.1 MRTR (#13) — in-band response shape', + ServerResult: 'M4.1 MRTR (#13) — the union gains InputRequiredResult', + CallToolRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + CallToolRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + GetPromptRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + GetPromptRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + ReadResourceRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + ReadResourceRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + CallToolResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', + GetPromptResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', + ReadResourceResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', - // SEP-2575 sessionless discovery: the SDK ships the wire shapes - // (DiscoverRequestSchema / DiscoverResultSchema), but the 2026-07-28 shapes embed the - // required `_meta` envelope (request) and required `resultType` (result → MRTR PR), - // and DiscoverResult now extends CacheableResult (required `ttlMs`/`cacheScope` - // → PR for SEP-2549), so they do not match yet; DiscoverResultResponse is a - // response wrapper (→ MRTR PR): - 'DiscoverRequest', - 'DiscoverResult', - 'DiscoverResultResponse', + // M6.1 subscriptions/listen (#14): + SubscriptionFilter: 'M6.1 subscriptions/listen (#14)', + SubscriptionsAcknowledgedNotification: 'M6.1 subscriptions/listen (#14)', + SubscriptionsAcknowledgedNotificationParams: 'M6.1 subscriptions/listen (#14)', + SubscriptionsListenRequest: 'M6.1 subscriptions/listen (#14)', + SubscriptionsListenRequestParams: 'M6.1 subscriptions/listen (#14)', + ClientRequest: 'M6.1 subscriptions/listen (#14) — the union gains SubscriptionsListenRequest', + ServerNotification: 'M6.1 subscriptions/listen (#14) — the union gains the acknowledged notification', - // SEP-2567 input requests/responses (new surface) → PR for MRTR: - 'InputRequest', - 'InputRequests', - 'InputRequiredResult', - 'InputResponse', - 'InputResponseRequestParams', - 'InputResponses', - - // 2026-07-28 subscriptions surface (new) → PR for subscriptions/listen: - 'SubscriptionFilter', - 'SubscriptionsAcknowledgedNotification', - 'SubscriptionsAcknowledgedNotificationParams', - 'SubscriptionsListenRequest', - 'SubscriptionsListenRequestParams', - - // New typed protocol errors: the SDK ships -32003/-32004 as ProtocolErrorCode - // entries plus the UnsupportedProtocolVersionError class (errors.ts); the spec's - // per-code error *response envelope* interfaces are not modeled as wire types: - 'MissingRequiredClientCapabilityError', - 'UnsupportedProtocolVersionError', + // M1.2 validation ladder (#8): the per-code error response envelopes: + MissingRequiredClientCapabilityError: 'M1.2 validation ladder (#8)', + UnsupportedProtocolVersionError: 'M1.2 validation ladder (#8)' +}; - // Other shapes changed in the 2026-07-28 schema: sampling content changes (SamplingMessage, - // SamplingMessageContentBlock, ToolResultContent) → backchannel PR; open tool - // input/output schema typing (Tool); loosened Notification.params (Notification); - // server notification union, which gains the subscriptions ack (ServerNotification → - // PR for subscriptions/listen): - 'SamplingMessage', - 'SamplingMessageContentBlock', - 'ToolResultContent', - 'Tool', - 'Notification', - 'ServerNotification' -]; +const MISSING_SDK_TYPES_2026_07_28 = Object.keys(FEATURE_OWNED_PENDING_2026); function extractExportedTypes(source: string): string[] { const matches = [...source.matchAll(/export\s+(?:interface|class|type)\s+(\w+)\b/g)]; @@ -540,7 +700,7 @@ describe('Spec Types (2026-07-28)', () => { const missingTests = []; for (const typeName of typesToCheck) { - if (!sdkTypeChecks[typeName as keyof typeof sdkTypeChecks]) { + if (!allTypeChecks[typeName as keyof typeof allTypeChecks]) { missingTests.push(typeName); } } @@ -548,12 +708,15 @@ describe('Spec Types (2026-07-28)', () => { expect(missingTests).toHaveLength(0); }); - describe('Missing SDK Types', () => { - it.each(MISSING_SDK_TYPES_2026_07_28)( - '%s should not be present in MISSING_SDK_TYPES_2026_07_28 if it has a compatibility test', - type => { - expect(sdkTypeChecks[type as keyof typeof sdkTypeChecks]).toBeUndefined(); + describe('Feature-owned pending entries', () => { + it.each(MISSING_SDK_TYPES_2026_07_28)('%s must not be pending once it has a parity check (stale-check)', type => { + expect(allTypeChecks[type as keyof typeof allTypeChecks]).toBeUndefined(); + }); + + it('every pending entry names its owner', () => { + for (const [name, owner] of Object.entries(FEATURE_OWNED_PENDING_2026)) { + expect(owner.length, name).toBeGreaterThan(0); } - ); + }); }); }); diff --git a/packages/core/test/types/schemaBoundaryPins.test.ts b/packages/core/test/types/schemaBoundaryPins.test.ts index 0f18151beb..a814ef36f8 100644 --- a/packages/core/test/types/schemaBoundaryPins.test.ts +++ b/packages/core/test/types/schemaBoundaryPins.test.ts @@ -17,6 +17,7 @@ import { describe, expect, test } from 'vitest'; import { CallToolRequestSchema, CallToolResultSchema, + ClientCapabilitiesSchema, CompleteResultSchema, EmptyResultSchema, JSONRPCErrorResponseSchema, @@ -27,7 +28,11 @@ import { } from '../../src/types/index.js'; // The per-request envelope is wire-only vocabulary and now lives in the // 2026-era wire module (Q1 increment 2); its accept/reject line is unchanged. -import { RequestMetaEnvelopeSchema } from '../../src/wire/rev2026-07-28/schemas.js'; +import { + ClientCapabilities2026Schema, + ListToolsResultSchema as Wire2026ListToolsResultSchema, + RequestMetaEnvelopeSchema +} from '../../src/wire/rev2026-07-28/schemas.js'; import type { CallToolResult, CompleteResult, @@ -200,6 +205,52 @@ describe('RequestMetaEnvelopeSchema', () => { const parsed = RequestMetaEnvelopeSchema.parse({ ...validEnvelope, 'com.example/custom': 'kept' }); expect((parsed as Record)['com.example/custom']).toBe('kept'); }); + + test('clientCapabilities are validated with the 2026 fork: tasks is not vocabulary on this revision', () => { + // The envelope composes ClientCapabilities2026Schema (the shared + // shape minus the deleted `tasks` key), matching the server-side + // fork wired into DiscoverResultSchema. A tasks-bearing claim is + // foreign vocabulary: it neither validates as a capability (a + // malformed value cannot reject the envelope) nor survives the parse. + const withMalformedTasks = { + ...validEnvelope, + 'io.modelcontextprotocol/clientCapabilities': { tasks: 'not-an-object' } + }; + expect(RequestMetaEnvelopeSchema.safeParse(withMalformedTasks).success).toBe(true); + + const parsed = RequestMetaEnvelopeSchema.parse({ + ...validEnvelope, + 'io.modelcontextprotocol/clientCapabilities': { sampling: {}, tasks: { requests: {} } } + }); + const capabilities = parsed['io.modelcontextprotocol/clientCapabilities'] as Record; + expect(capabilities.sampling).toEqual({}); + expect('tasks' in capabilities).toBe(false); + }); + + test('the 2026 client-capabilities fork tracks the shared shape exactly (minus tasks, by reference)', () => { + // The fork lists its members explicitly (dts-rollup determinism — see + // rev2026-07-28/schemas.ts); this oracle keeps the explicit list from + // drifting: same keys as the shared schema minus `tasks`, and every + // member is the SAME schema object, composed by reference. + const sharedKeys = Object.keys(ClientCapabilitiesSchema.shape).filter(key => key !== 'tasks'); + expect(Object.keys(ClientCapabilities2026Schema.shape)).toEqual(sharedKeys); + for (const key of sharedKeys) { + expect( + (ClientCapabilities2026Schema.shape as Record)[key], + `member '${key}' must be composed by reference from the shared shape` + ).toBe((ClientCapabilitiesSchema.shape as Record)[key]); + } + }); +}); + +describe('2026 wire result members', () => { + test('ttlMs is an integer at the wire boundary (anchor parity: the twin says integer)', () => { + // Type-level parity is structurally blind to this (TS can only say + // `number`), so pin it at the runtime boundary. + const base = { resultType: 'complete', ttlMs: 1500, cacheScope: 'public', tools: [] }; + expect(Wire2026ListToolsResultSchema.safeParse(base).success).toBe(true); + expect(Wire2026ListToolsResultSchema.safeParse({ ...base, ttlMs: 1500.5 }).success).toBe(false); + }); }); // ---- Key-existence checks for consumer-read result members ---- diff --git a/packages/core/test/wire/eraGates.test.ts b/packages/core/test/wire/eraGates.test.ts new file mode 100644 index 0000000000..97d2ba3313 --- /dev/null +++ b/packages/core/test/wire/eraGates.test.ts @@ -0,0 +1,572 @@ +/** + * Physical deletions through real dispatch (Q1 increment 2). + * + * Era is INSTANCE state: the negotiated protocol version held by the + * Protocol instance selects the wire codec for everything the connection + * sends and receives. Legacy is the default (hand-constructed instances and + * pre-negotiation traffic); modern-era instances get their version set + * through the package-internal hook (`setNegotiatedProtocolVersion`) — the + * same channel the modern-era server entry will use at instance binding. + * + * Registry membership is the deletion story, and these tests prove it at the + * protocol funnels, in both directions: + * + * - inbound: `tasks/get` on a modern-era instance gets −32601 BY ABSENCE — + * even with a handler registered (a custom handler cannot shadow a + * deleted spec method across eras); era-deleted spec notifications are + * silently dropped even with a handler registered. + * - outbound: an era-mismatched spec method dies locally with + * `SdkErrorCode.MethodNotSupportedByProtocolVersion` before anything + * reaches the transport. + * - the 2026 era requires the per-request envelope (−32602 when missing). + * - the stamp seam: 2026-era responses carry `resultType: 'complete'`; + * 2025-era responses NEVER carry it (the 2025 codec has no stamp code + * path — the never-stamp guarantee). + * - encode-side deleted-field strictness (Q1-SD3 iii): `execution` is + * stripped from tools and `tasks` from capability objects on 2026-era + * emissions; both survive untouched on the 2025 era. + * + * `MessageExtraInfo.classification` (INJECTED here; the production + * classifier is the entry/edge's job) no longer selects the era per message: + * the funnel VALIDATES it against the instance era — a mismatch is an + * entry/routing error (typed −32004 rejection / notification drop, plus + * onerror), and unclassified traffic on a legacy instance behaves exactly as + * before the codec split (the B-2 rule). + */ +import { describe, expect, test } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import type { JSONRPCMessage, MessageClassification, Result } from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import * as z from 'zod/v4'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const MODERN: MessageClassification = { era: 'modern', revision: '2026-07-28' }; + +const ENVELOPE = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'era-client', version: '0.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +interface Harness { + receiver: TestProtocol; + /** Deliver a raw message to the receiver, optionally classified. */ + deliver: (message: JSONRPCMessage, classification?: MessageClassification) => void; + /** Messages the receiver sent back (responses, notifications). */ + sent: JSONRPCMessage[]; + /** Out-of-band errors surfaced via the receiver's onerror. */ + errors: Error[]; + flush: () => Promise; +} + +interface HarnessOptions { + /** + * Marks the instance's era through the package-internal hook (the same + * channel the modern-era server entry uses at instance binding). Omitted + * = legacy default, exactly like a hand-constructed instance. + */ + era?: '2025-11-25' | '2026-07-28'; + setup?: (receiver: TestProtocol) => void; +} + +async function harness(options: HarnessOptions = {}): Promise { + const [peerTx, receiverTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const receiver = new TestProtocol(); + const errors: Error[] = []; + receiver.onerror = error => void errors.push(error); + options.setup?.(receiver); + if (options.era !== undefined) setNegotiatedProtocolVersion(receiver, options.era); + await receiver.connect(receiverTx); + + return { + receiver, + // Invoke the receiver-side transport callback directly so the test + // controls MessageExtraInfo (the classification handoff seam). + deliver: (message, classification) => receiverTx.onmessage?.(message, classification ? ({ classification } as never) : undefined), + sent, + errors, + flush: () => new Promise(resolve => setTimeout(resolve, 10)) + }; +} + +const errorOf = (msg: JSONRPCMessage | undefined) => (msg as { error?: { code: number; message: string } } | undefined)?.error; +const resultOf = (msg: JSONRPCMessage | undefined) => (msg as { result?: Record } | undefined)?.result; + +describe('inbound era gates — deletions are physical, era is instance state', () => { + const registerTasksGetHandler = (onRun: () => void) => (receiver: TestProtocol) => { + // A custom (3-arg) handler deliberately shadowing the deleted + // spec method: it may serve the 2025 era only. + receiver.setRequestHandler('tasks/get', { params: z.looseObject({ taskId: z.string() }) }, () => { + onRun(); + return {} as Result; + }); + }; + + test('a modern-era instance answers tasks/get with −32601 BY ABSENCE even with a handler registered', async () => { + let handlerRan = false; + const h = await harness({ era: '2026-07-28', setup: registerTasksGetHandler(() => (handlerRan = true)) }); + + // A matching modern classification rides along untouched — the + // handoff check accepts it; the era gate still answers by absence. + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(handlerRan).toBe(false); + expect(h.sent).toHaveLength(1); + expect(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' }); + }); + + test('a legacy-era instance (the default) serves tasks/get with that handler — era is fixed per instance', async () => { + let handlerRan = false; + const h = await harness({ setup: registerTasksGetHandler(() => (handlerRan = true)) }); + + // Unclassified, hand-wired instance ⇒ legacy default (B-2): exactly + // the pre-split behavior. + h.deliver({ jsonrpc: '2.0', id: 2, method: 'tasks/get', params: { taskId: 't-1' } } as JSONRPCMessage); + await h.flush(); + + expect(handlerRan).toBe(true); + expect(resultOf(h.sent[0])).toBeDefined(); + }); + + test('ping on a modern-era instance is −32601 by absence (the built-in pong cannot cross eras)', async () => { + const modern = await harness({ era: '2026-07-28' }); + modern.deliver({ jsonrpc: '2.0', id: 3, method: 'ping', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await modern.flush(); + expect(errorOf(modern.sent[0])).toMatchObject({ code: -32601 }); + + // …while a legacy-era instance keeps the automatic pong. + const legacy = await harness(); + legacy.deliver({ jsonrpc: '2.0', id: 4, method: 'ping' } as JSONRPCMessage); + await legacy.flush(); + expect(resultOf(legacy.sent[0])).toEqual({}); + }); + + test('a spec notification the modern era deleted is dropped even with a handler', async () => { + let delivered = 0; + const registerHandler = (receiver: TestProtocol) => { + receiver.setNotificationHandler('notifications/tasks/status', { params: z.looseObject({}) }, () => { + delivered += 1; + }); + }; + + const modern = await harness({ era: '2026-07-28', setup: registerHandler }); + modern.deliver( + { jsonrpc: '2.0', method: 'notifications/tasks/status', params: { taskId: 't', status: 'working' } } as JSONRPCMessage, + MODERN + ); + await modern.flush(); + expect(delivered).toBe(0); + + // Legacy-era instance: delivered. + const legacy = await harness({ setup: registerHandler }); + legacy.deliver({ + jsonrpc: '2.0', + method: 'notifications/tasks/status', + params: { taskId: 't', status: 'working' } + } as JSONRPCMessage); + await legacy.flush(); + expect(delivered).toBe(1); + }); + + test('out-of-universe custom methods stay era-blind (consumer-owned)', async () => { + let served = 0; + const registerHandler = (receiver: TestProtocol) => { + receiver.setRequestHandler('acme/anything', { params: z.looseObject({}) }, () => { + served += 1; + return {} as Result; + }); + }; + + // Served on a modern-era instance (envelope present, as 2026 requires)… + const modern = await harness({ era: '2026-07-28', setup: registerHandler }); + modern.deliver({ jsonrpc: '2.0', id: 5, method: 'acme/anything', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + // …and on a legacy-era instance, bare: the era gate never blocks + // methods outside the spec universe on either era. + const legacy = await harness({ setup: registerHandler }); + legacy.deliver({ jsonrpc: '2.0', id: 6, method: 'acme/anything', params: {} } as JSONRPCMessage); + + await modern.flush(); + await legacy.flush(); + expect(served).toBe(2); + }); +}); + +describe('2026-era envelope requiredness at dispatch', () => { + test('a modern-era request without the envelope is −32602 naming the requirement', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage, MODERN); + await h.flush(); + + const error = errorOf(h.sent[0]); + expect(error?.code).toBe(-32602); + expect(error?.message).toContain('_meta envelope'); + }); + + test('a modern-era request with a valid envelope is served (handler sees the 2025 shape)', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + expect(resultOf(h.sent[0])).toMatchObject({ tools: [] }); + }); + + test('the 2025 era never requires an envelope', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + expect(resultOf(h.sent[0])).toMatchObject({ tools: [] }); + }); + + test('−32601 outranks the missing envelope: unknown/era-deleted/unserved methods answer method-not-found', async () => { + // Method existence outranks parameter validity (the canonical + // precedence table for the full inbound validation ladder arrives + // with the validation-ladder milestone; this pins the + // −32601-over-−32602 rule on the modern leg). All three −32601 + // producers win over the envelope −32602: + const h = await harness({ era: '2026-07-28' }); + + // (a) out-of-universe method, no handler registered; + h.deliver({ jsonrpc: '2.0', id: 4, method: 'acme/no-such-method', params: {} } as JSONRPCMessage, MODERN); + // (b) spec method deleted from the era (the era gate runs first); + h.deliver({ jsonrpc: '2.0', id: 5, method: 'tasks/get', params: { taskId: 't-1' } } as JSONRPCMessage, MODERN); + // (c) spec method IN era but with no handler registered. + h.deliver({ jsonrpc: '2.0', id: 6, method: 'tools/list', params: {} } as JSONRPCMessage, MODERN); + await h.flush(); + + expect(h.sent).toHaveLength(3); + for (const message of h.sent) { + expect(errorOf(message)).toMatchObject({ code: -32601, message: 'Method not found' }); + } + }); +}); + +describe('the stamp seam and the never-stamp guarantee', () => { + test('2026-era responses are stamped resultType: complete', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + expect(resultOf(h.sent[0])).toMatchObject({ resultType: 'complete' }); + }); + + test('2025-era responses NEVER carry resultType (no stamp code path exists)', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + + const result = resultOf(h.sent[0]); + expect(result).toBeDefined(); + expect(result && 'resultType' in result).toBe(false); + }); + + test('the 2025 codec encodeResult is the identity (same reference, nothing added)', async () => { + const { rev2025Codec } = await import('../../src/wire/rev2025-11-25/codec.js'); + const result = { content: [{ type: 'text', text: 'x' }] } as unknown as Result; + expect(rev2025Codec.encodeResult('tools/call', result)).toBe(result); + }); +}); + +describe('encode-side deleted-field strictness (Q1-SD3 iii)', () => { + const TOOL_WITH_EXECUTION = { + name: 'legacy-tool', + inputSchema: { type: 'object' }, + execution: { taskSupport: 'optional' } + }; + + test('execution.taskSupport is stripped from 2026-era tools/list emissions', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', (() => ({ tools: [TOOL_WITH_EXECUTION] })) as never); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + const tools = resultOf(h.sent[0])?.tools as Array>; + expect(tools[0]).toMatchObject({ name: 'legacy-tool' }); + expect('execution' in tools[0]!).toBe(false); + }); + + test('the same handler emits execution untouched on the 2025 era (era-invisible handlers)', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', (() => ({ tools: [TOOL_WITH_EXECUTION] })) as never); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + + const tools = resultOf(h.sent[0])?.tools as Array>; + expect(tools[0]).toMatchObject({ name: 'legacy-tool', execution: { taskSupport: 'optional' } }); + }); + + test('capabilities.tasks is stripped from 2026-era capability-carrying emissions (server/discover)', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler( + 'server/discover' as never, + (() => ({ + ttlMs: 0, + cacheScope: 'private', + supportedVersions: ['2026-07-28'], + capabilities: { tools: {}, tasks: { list: {} } }, + serverInfo: { name: 's', version: '0' } + })) as never + ); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 3, method: 'server/discover', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + const result = resultOf(h.sent[0]); + expect(result).toMatchObject({ resultType: 'complete', capabilities: { tools: {} } }); + expect('tasks' in (result?.capabilities as Record)).toBe(false); + }); +}); + +describe('the edge→instance handoff — classification is validated, never an era switch', () => { + test('a modern-classified request on a legacy-era instance is an entry/routing error: typed −32004, handler never runs', async () => { + let handlerRan = false; + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', () => { + handlerRan = true; + return { tools: [] }; + }); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + expect(handlerRan).toBe(false); + expect(h.sent).toHaveLength(1); + const error = errorOf(h.sent[0]); + expect(error?.code).toBe(-32004); + expect(error?.message).toContain('Unsupported protocol version'); + // Surfaced out of band too: the mismatch is the entry's bug, not the peer's. + expect(h.errors.some(e => e.message.includes('Era mismatch'))).toBe(true); + }); + + test('a legacy-classified request on a modern-era instance is rejected the same way', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} } as JSONRPCMessage, { + era: 'legacy', + revision: '2025-11-25' + }); + await h.flush(); + + expect(errorOf(h.sent[0])).toMatchObject({ code: -32004 }); + expect(h.errors.some(e => e.message.includes('Era mismatch'))).toBe(true); + }); + + test('a modern-classified notification on a legacy-era instance is dropped, with onerror', async () => { + let delivered = 0; + const h = await harness({ + setup: receiver => { + receiver.setNotificationHandler('notifications/progress', () => { + delivered += 1; + }); + } + }); + + h.deliver( + { jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: 1, progress: 1 } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(delivered).toBe(0); + expect(h.sent).toHaveLength(0); + expect(h.errors.some(e => e.message.includes('Era mismatch'))).toBe(true); + }); + + test('a matching classification rides along untouched (and unclassified legacy traffic is byte-identical — B-2)', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + // Matching legacy classification. + h.deliver({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} } as JSONRPCMessage, { era: 'legacy' }); + // Unclassified (the hand-wired transport posture). + h.deliver({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + + expect(h.sent).toHaveLength(2); + expect(resultOf(h.sent[0])).toMatchObject({ tools: [] }); + expect(resultOf(h.sent[1])).toMatchObject({ tools: [] }); + expect(h.errors).toHaveLength(0); + }); +}); + +describe('outbound era gates — typed local error before the transport', () => { + test('a 2026-era instance cannot send 2025-only spec methods', async () => { + const h = await harness({ era: '2026-07-28' }); + + for (const method of ['tasks/get', 'ping', 'logging/setLevel', 'resources/subscribe']) { + const attempt = () => h.receiver.request({ method } as never); + expect(attempt, method).toThrow(SdkError); + try { + attempt(); + } catch (error) { + expect((error as SdkError).code, method).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).data, method).toMatchObject({ method, era: '2026-07-28' }); + } + } + // Nothing reached the transport. + expect(h.sent).toHaveLength(0); + }); + + test('a legacy-era instance cannot send server/discover', async () => { + const h = await harness({ era: '2025-11-25' }); + + expect(() => h.receiver.request({ method: 'server/discover' } as never)).toThrow(SdkError); + try { + h.receiver.request({ method: 'server/discover' } as never); + } catch (error) { + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + } + expect(h.sent).toHaveLength(0); + }); + + test('outbound era-mismatched spec notifications die locally too', async () => { + const h = await harness({ era: '2026-07-28' }); + + await expect(h.receiver.notification({ method: 'notifications/roots/list_changed' })).rejects.toMatchObject({ + code: SdkErrorCode.MethodNotSupportedByProtocolVersion + }); + expect(h.sent).toHaveLength(0); + }); + + test('pre-negotiation bootstrap pins still route initialize to the 2025 era', async () => { + // An instance with NO negotiated version may always send the legacy + // handshake; setting a modern version afterwards closes it (the pin + // applies only while the negotiated version is unset — a negotiated + // session never re-routes onto the other era). + const h = await harness(); + const pending = h.receiver.request({ + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '0' } } + }); + pending.catch(() => undefined); // unanswered; we only assert the send happened + await h.flush(); + // The handshake reached the wire (sent[] captures the peer's inbox). + expect(h.sent).toHaveLength(1); + expect((h.sent[0] as { method?: string }).method).toBe('initialize'); + await h.receiver.close(); + + const h2 = await harness({ era: '2026-07-28' }); + expect(() => + h2.receiver.request({ + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '0' } } + }) + ).toThrow(SdkError); + }); +}); + +describe('T6 width-leak killed at both roots', () => { + test('2026 era: a task-shaped tools/call body can never parse as an empty success', async () => { + const { rev2026Codec } = await import('../../src/wire/rev2026-07-28/codec.js'); + // resultType present-and-complete but the body is task-shaped: the + // wire-exact parse requires content — loud invalid, never {content: []}. + const decoded = rev2026Codec.decodeResult('tools/call', { + resultType: 'complete', + task: { taskId: 't-1', status: 'working' } + }); + expect(decoded.kind).toBe('invalid'); + }); + + test('2025 era: with the content default gone, a bare task-shaped body fails the plain schema loudly', async () => { + const { rev2025Codec } = await import('../../src/wire/rev2025-11-25/codec.js'); + const { CallToolResultSchema } = await import('../../src/types/schemas.js'); + const decoded = rev2025Codec.decodeResult('tools/call', { task: { taskId: 't-1', status: 'working' } }); + expect(decoded.kind).toBe('complete'); + if (decoded.kind === 'complete') { + // The plain schema (which IS the registry entry — the result map + // is aligned to the typed map, no task-widened union): no + // default([]) means no silent {content: []} masking. + expect(CallToolResultSchema.safeParse(decoded.result).success).toBe(false); + } + // The GENERIC path agrees: the registry serves the same plain schema, + // so even a fully conforming CreateTaskResult body is a loud schema + // failure (surfaced as a typed INVALID_RESULT — see + // test/shared/typedMapAlignment.test.ts). Task interop is the + // explicit-schema overload, never a silent union member. + const { getResultSchema } = await import('../../src/wire/rev2025-11-25/registry.js'); + const plain = getResultSchema('tools/call'); + expect(plain).toBe(CallToolResultSchema); + expect( + plain!.safeParse({ + task: { + taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', + status: 'working', + createdAt: '2025-11-25T10:30:00Z', + lastUpdatedAt: '2025-11-25T10:30:05Z', + ttl: 60000, + pollInterval: 5000 + } + }).success + ).toBe(false); + }); +}); diff --git a/packages/core/test/wire/neutralKeyParity.test.ts b/packages/core/test/wire/neutralKeyParity.test.ts new file mode 100644 index 0000000000..316513541b --- /dev/null +++ b/packages/core/test/wire/neutralKeyParity.test.ts @@ -0,0 +1,98 @@ +/** + * The neutralKeys pin family (Q1 increment 3): + * + * neutralKeys(T) = wireKeys@rev(T) − WIRE_ONLY + * + * For every mapped result type, the NEUTRAL public type's declared keys must + * equal the revision's WIRE type's declared keys minus the wire-only set + * (`resultType` — the envelope keys and retry fields are params-side and + * never appear on result types). This closes BOTH inherited verification + * holes at once: + * - the old 2025 suite tolerated a phantom `resultType` key on every result + * (`AssertExactKeysWithResultType`), and + * - the old 2026 suite had no key parity at all. + * + * OWNED PENDING DELTA (stale-checked): the 2026 cacheable results carry + * `ttlMs`/`cacheScope` on the wire. Those are CONSUMER-RELEVANT (cache fields + * are deliberately NOT wire-only — Q13) but the neutral model does not carry + * them until the cache-hint surface lands (M3.2/#12). Each cacheable entry + * below subtracts them explicitly; when M3.2 models them neutrally, the + * subtraction breaks the build and the entry burns. + */ +import { describe, expect, test } from 'vitest'; +import type * as z4 from 'zod/v4'; + +import type * as SDK from '../../src/types/index.js'; +import type * as Wire2026 from '../../src/wire/rev2026-07-28/schemas.js'; + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +type KnownKeys = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + +type AssertSameKeys = [KnownKeys] extends [KnownKeys] + ? [KnownKeys] extends [KnownKeys] + ? true + : { _brand: 'KeyMismatch'; missingFromA: Exclude, KnownKeys> } + : { _brand: 'KeyMismatch'; extraInA: Exclude, KnownKeys> }; + +type Assert = T; + +/** The wire-only key set on results (the hide set's result-side member). */ +type WIRE_ONLY = 'resultType'; + +/** M3.2-owned pending delta: cache fields modeled on the wire, not yet neutrally. */ +type M32_PENDING = 'ttlMs' | 'cacheScope'; + +type MinusWireOnly = { [K in keyof T as K extends WIRE_ONLY ? never : K]: T[K] }; +type MinusWireOnlyAndCache = { [K in keyof T as K extends WIRE_ONLY | M32_PENDING ? never : K]: T[K] }; + +/* ---- 2026: neutralKeys(T) = wireKeys@2026(T) − WIRE_ONLY ---- */ + +type _N26_Result = Assert>>>; +type _N26_EmptyResult = Assert>>>; +type _N26_CallToolResult = Assert>>>; +type _N26_CompleteResult = Assert>>>; +type _N26_GetPromptResult = Assert>>>; +// Cacheable results: ttlMs/cacheScope subtracted until M3.2 models them neutrally. +type _N26_ListToolsResult = Assert< + AssertSameKeys>> +>; +type _N26_ListPromptsResult = Assert< + AssertSameKeys>> +>; +type _N26_ListResourcesResult = Assert< + AssertSameKeys>> +>; +type _N26_ListResourceTemplatesResult = Assert< + AssertSameKeys>> +>; +type _N26_ReadResourceResult = Assert< + AssertSameKeys>> +>; +type _N26_DiscoverResult = Assert< + AssertSameKeys>> +>; + +/* ---- 2025: the wire schemas ARE the neutral schemas post-cut — pin that no + * result type re-grows a resultType slot (the masking surface stays dead). ---- */ + +type DeclaresResultType = 'resultType' extends KnownKeys ? true : false; +type _N25_Result = Assert extends false ? true : false>; +type _N25_EmptyResult = Assert extends false ? true : false>; +type _N25_CallToolResult = Assert extends false ? true : false>; +type _N25_InitializeResult = Assert extends false ? true : false>; +type _N25_CreateMessageResult = Assert extends false ? true : false>; +type _N25_ElicitResult = Assert extends false ? true : false>; +type _N25_ListRootsResult = Assert extends false ? true : false>; +type _N25_GetTaskResult = Assert extends false ? true : false>; +type _N25_ClientResult = Assert extends false ? true : false>; +type _N25_ServerResult = Assert extends false ? true : false>; + +describe('neutralKeys pin family', () => { + test('the compile of this file IS the assertion (runtime guard against truncation)', () => { + // 11 per-type 2026 pins + 10 resultType-absence pins are enforced at + // type level above; this runtime test exists so the file cannot be + // silently excluded from the suite. + expect(true).toBe(true); + }); +}); diff --git a/packages/core/test/wire/registryDiffOracle.test.ts b/packages/core/test/wire/registryDiffOracle.test.ts new file mode 100644 index 0000000000..c782e16664 --- /dev/null +++ b/packages/core/test/wire/registryDiffOracle.test.ts @@ -0,0 +1,104 @@ +/** + * Registry-diff oracle (Q1 increment 3 — generation as ORACLE, never source). + * + * The per-era method registries are HAND-WRITTEN (a generator walking anchor + * method literals would silently re-admit the 2026-demoted server→client + * methods — the flavor-(b) trap). This oracle derives each revision's method + * universe FROM THE ANCHOR SOURCE at test time and fails LOUD — with the + * exact diff — whenever the anchor and the hand registry disagree, modulo a + * documented seed-decision list that is stale-checked in both directions. + * + * Seed decisions (every entry is a deliberate, owned divergence): + * - 2026 DEMOTIONS: `sampling/createMessage`, `elicitation/create`, + * `roots/list` keep method literals in the anchor but are NOT wire request + * methods in 2026 — the server→client JSON-RPC request channel is deleted + * (`ServerRequest` has no 2026 export; the shapes survive only as in-band + * `InputRequest` payloads, M4.1/#13). + * - 2026 DEFERRALS: `subscriptions/listen` and + * `notifications/subscriptions/acknowledged` are real 2026 wire methods + * whose SHELLS land with the subscriptions feature (M6.1/#14). The day #14 + * wires them, this oracle fails until the entries are removed — that + * failure is the burn-down notification, by design. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { rev2025NotificationMethods, rev2025RequestMethods } from '../../src/wire/rev2025-11-25/registry.js'; +import { rev2026NotificationMethods, rev2026RequestMethods } from '../../src/wire/rev2026-07-28/registry.js'; + +const ANCHORS = { + '2025-11-25': path.resolve(__dirname, '../../src/types/spec.types.2025-11-25.ts'), + '2026-07-28': path.resolve(__dirname, '../../src/types/spec.types.2026-07-28.ts') +} as const; + +/** Extract every `method: ''` from an anchor source. */ +function anchorMethods(revision: keyof typeof ANCHORS): { requests: string[]; notifications: string[] } { + const source = fs.readFileSync(ANCHORS[revision], 'utf8'); + const literals = [...source.matchAll(/method:\s*'([^']+)'/g)].map(m => m[1]!); + const unique = [...new Set(literals)].sort(); + return { + requests: unique.filter(m => !m.startsWith('notifications/')), + notifications: unique.filter(m => m.startsWith('notifications/')) + }; +} + +/** Anchor-side methods deliberately NOT in the hand registry (reason per entry). */ +const SEED_EXCLUSIONS: Record> = { + '2025-11-25': {}, + '2026-07-28': { + 'sampling/createMessage': 'DEMOTED to an in-band InputRequest payload (M4.1/#13) — not a 2026 wire request', + 'elicitation/create': 'DEMOTED to an in-band InputRequest payload (M4.1/#13) — not a 2026 wire request', + 'roots/list': 'DEMOTED to an in-band InputRequest payload (M4.1/#13) — not a 2026 wire request', + 'subscriptions/listen': 'DEFERRED to the subscriptions feature (M6.1/#14)', + 'notifications/subscriptions/acknowledged': 'DEFERRED to the subscriptions feature (M6.1/#14)' + } +}; + +const REGISTRIES = { + '2025-11-25': { requests: rev2025RequestMethods, notifications: rev2025NotificationMethods }, + '2026-07-28': { requests: rev2026RequestMethods, notifications: rev2026NotificationMethods } +} as const; + +describe.each(['2025-11-25', '2026-07-28'] as const)('registry-diff oracle %s', revision => { + const anchor = anchorMethods(revision); + const registry = REGISTRIES[revision]; + const exclusions = SEED_EXCLUSIONS[revision]!; + + test('every anchor method is in the hand registry or a documented seed exclusion', () => { + const missing = [...anchor.requests, ...anchor.notifications].filter(method => { + const inRegistry = registry.requests.includes(method) || registry.notifications.includes(method); + return !inRegistry && !(method in exclusions); + }); + expect( + missing, + `Anchor methods absent from the ${revision} registry with NO seed decision — ` + + `wire them or add a documented exclusion (this is the loud failure the oracle exists for)` + ).toEqual([]); + }); + + test('the hand registry contains nothing beyond the anchor universe', () => { + const anchorSet = new Set([...anchor.requests, ...anchor.notifications]); + const extra = [...registry.requests, ...registry.notifications].filter(method => !anchorSet.has(method)); + expect(extra, `Registry methods with no ${revision} anchor literal — era leak or typo`).toEqual([]); + }); + + test('seed exclusions are not stale (still in the anchor, still not in the registry)', () => { + for (const [method, reason] of Object.entries(exclusions)) { + const inAnchor = anchor.requests.includes(method) || anchor.notifications.includes(method); + expect(inAnchor, `${method}: exclusion no longer matches any anchor literal — remove it (${reason})`).toBe(true); + const inRegistry = registry.requests.includes(method) || registry.notifications.includes(method); + expect(inRegistry, `${method}: now wired in the registry — remove the stale exclusion (${reason})`).toBe(false); + } + }); + + test('the anchor universe is fully partitioned (sanity: counts add up)', () => { + const total = anchor.requests.length + anchor.notifications.length; + const covered = + registry.requests.filter(m => anchor.requests.includes(m)).length + + registry.notifications.filter(m => anchor.notifications.includes(m)).length + + Object.keys(exclusions).length; + expect(covered).toBe(total); + }); +}); diff --git a/packages/core/test/wire/schemaTwinConformance.test.ts b/packages/core/test/wire/schemaTwinConformance.test.ts new file mode 100644 index 0000000000..e0f4b21dca --- /dev/null +++ b/packages/core/test/wire/schemaTwinConformance.test.ts @@ -0,0 +1,126 @@ +/** + * Schema-twin conformance lock (Q1 increment 3 — generation as ORACLE). + * + * The spec repository generates `schema.json` from the same normative + * `schema.ts` the anchors vendor. The twins vendored under + * `corpus/schema-twins/` (TEST-ONLY — never bundled, never runtime; the + * engines stay optional peers and the hot path stays hand-written Zod) give + * a generated, revision-exact validator for every named spec type. This + * suite locks the hand-written wire layer to them, per revision per fixture: + * + * - every accept-corpus fixture must satisfy the GENERATED validator for its + * directory's spec type (catches twin/anchor desync and hand-corpus drift + * — the 2025 mini-corpus is hand-built, so this is its only independent + * referee), and + * - every fixture the SDK wire layer accepts must also be twin-valid + * (agreement on the accept side; reject-side deltas are owned by the + * dispatch-routed rejection corpus, since generated valid-only oracles are + * blind to them). + * + * Twin refresh is ATOMIC with the matching anchor (lifecycle rule 4, + * packages/core/src/types/README.md); provenance in schema-twins/manifest.json. + */ +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import { Ajv2020 as Ajv } from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import { describe, expect, test } from 'vitest'; + +const FIXTURES_ROOT = join(__dirname, '../corpus/fixtures'); +const TWINS_ROOT = join(__dirname, '../corpus/schema-twins'); + +interface TwinManifest { + source: { repository: string; commit: string }; + files: Record; +} + +const TWIN_MANIFEST = JSON.parse(readFileSync(join(TWINS_ROOT, 'manifest.json'), 'utf8')) as TwinManifest; + +describe('twin provenance integrity (the manifest lock)', () => { + // The twins' authority as generated oracles rests on them being the raw + // upstream artifacts, byte for byte. Hash the vendored files against the + // manifest's provenance values at test time so ANY rewrite — prettier, an + // editor, a manual touch-up — fails loudly. Refresh only via + // `pnpm fetch:schema-twins` (which recomputes these values from the + // fetched bytes), atomically with the matching spec.types anchor. + test.each(Object.keys(TWIN_MANIFEST.files))('%s twin is byte-identical to the upstream artifact pinned in the manifest', revision => { + const entry = TWIN_MANIFEST.files[revision]!; + const raw = readFileSync(join(TWINS_ROOT, `${revision}.schema.json`)); + expect(raw.byteLength, `byte size drifted for ${revision} — the vendored twin was rewritten`).toBe(entry.bytes); + expect( + createHash('sha256').update(raw).digest('hex'), + `sha256 drifted for ${revision} — the vendored twin was rewritten (re-vendor raw bytes via pnpm fetch:schema-twins)` + ).toBe(entry.sha256); + }); +}); + +type JsonSchema = { $defs?: Record }; + +function twinValidatorFactory(revision: string) { + const schema = JSON.parse(readFileSync(join(TWINS_ROOT, `${revision}.schema.json`), 'utf8')) as JsonSchema; + const ajv = new Ajv({ strict: false, allowUnionTypes: true }); + addFormats.default ? addFormats.default(ajv) : (addFormats as unknown as (a: Ajv) => void)(ajv); + ajv.addSchema(schema, 'spec'); + return { + defs: new Set(Object.keys(schema.$defs ?? {})), + requiredOf(typeName: string): string[] { + return schema.$defs?.[typeName]?.required ?? []; + }, + validatorFor(typeName: string) { + return ajv.getSchema(`spec#/$defs/${typeName}`); + } + }; +} + +function listTypeDirs(revision: string): string[] { + const root = join(FIXTURES_ROOT, revision); + return readdirSync(root) + .filter(entry => statSync(join(root, entry)).isDirectory()) + .sort(); +} + +function listFixtures(revision: string, dir: string): string[] { + return readdirSync(join(FIXTURES_ROOT, revision, dir)) + .filter(file => file.endsWith('.json')) + .sort(); +} + +describe.each(['2025-11-25', '2026-07-28'] as const)('schema-twin conformance lock %s', revision => { + const twin = twinValidatorFactory(revision); + const dirs = listTypeDirs(revision).filter(dir => twin.defs.has(dir)); + + test('the twin covers the corpus (the unmapped set is pinned exactly)', () => { + const unmapped = listTypeDirs(revision).filter(dir => !twin.defs.has(dir)); + // Unmapped directories would be SDK-named shapes with no spec def. + // Today there are NONE — the set is pinned exactly, not bounded with + // slack: a new unmapped directory means the twin and the corpus are + // drifting apart and must be adjudicated here by name. + expect(unmapped).toEqual([]); + expect(dirs.length).toBeGreaterThan(30); + }); + + describe.each(dirs)('%s', dir => { + test.each(listFixtures(revision, dir))('%s satisfies the generated spec validator', file => { + let fixture = JSON.parse(readFileSync(join(FIXTURES_ROOT, revision, dir, file), 'utf8')) as Record; + // The hand-built 2025 mini-corpus stores BARE message shapes (the + // SDK parse surface); the spec defs model the full JSON-RPC wire + // message. Supply the neutral envelope members the def requires + // and the fixture deliberately omits — the PAYLOAD is what the + // fixtures pin, and it crosses to the twin verbatim. + const required = twin.requiredOf(dir); + if (typeof fixture === 'object' && fixture !== null && !('jsonrpc' in fixture)) { + if (required.includes('jsonrpc')) fixture = { jsonrpc: '2.0', ...fixture }; + if (required.includes('id') && !('id' in fixture)) fixture = { id: 'twin-probe', ...fixture }; + } + const validate = twin.validatorFor(dir); + expect(validate, `no compiled validator for ${dir}`).toBeDefined(); + const valid = validate!(fixture); + expect( + valid, + `'${dir}/${file}' rejected by the generated ${revision} validator:\n${JSON.stringify(validate!.errors, null, 2)}` + ).toBe(true); + }); + }); +}); diff --git a/scripts/fetch-schema-twins.ts b/scripts/fetch-schema-twins.ts new file mode 100644 index 0000000000..c6464e38b6 --- /dev/null +++ b/scripts/fetch-schema-twins.ts @@ -0,0 +1,73 @@ +/** + * Vendors the generated `schema.json` twins from the spec repository into + * `packages/core/test/corpus/schema-twins/` as RAW UPSTREAM BYTES. + * + * The twins are TEST-ONLY conformance oracles (never bundled, never runtime): + * `packages/core/test/wire/schemaTwinConformance.test.ts` compiles them into + * generated validators and locks the hand-written wire layer to them. Their + * authority rests on provenance, so they are vendored verbatim — no + * formatting of any kind (the directory is .prettierignore'd) — and each file + * is locked to the manifest's sha256/byte values at test time. Any rewrite + * (prettier, an editor, a manual touch-up) turns CI red. + * + * Refresh ATOMICALLY with the matching spec.types anchor (see + * packages/core/src/types/README.md lifecycle rule 4). + * + * Usage: + * pnpm fetch:schema-twins [sha] # default: the manifest's current source commit + * + * Sources are fetched from GitHub at the given commit, mirroring + * scripts/fetch-spec-types.ts; the manifest's provenance values (source + * commit, sha256, byte size) are recomputed from the fetched bytes. + */ + +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const PROJECT_ROOT = join(dirname(__filename), '..'); + +const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol'; +const TWINS_DIR = join(PROJECT_ROOT, 'packages', 'core', 'test', 'corpus', 'schema-twins'); +const MANIFEST_PATH = join(TWINS_DIR, 'manifest.json'); + +interface TwinManifest { + comment: string; + source: { repository: string; commit: string }; + files: Record; +} + +async function fetchRawBytes(sha: string, upstreamPath: string): Promise { + const url = `https://raw.githubusercontent.com/${SPEC_REPO}/${sha}/${upstreamPath}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${upstreamPath}: ${response.status} ${response.statusText}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +async function main(): Promise { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as TwinManifest; + const sha = process.argv[2] ?? manifest.source.commit; + + for (const [revision, entry] of Object.entries(manifest.files)) { + console.log(`[${revision}] Fetching ${entry.upstreamPath} at ${sha}`); + const bytes = await fetchRawBytes(sha, entry.upstreamPath); + // Verbatim: the twin IS the upstream artifact, byte for byte. + writeFileSync(join(TWINS_DIR, `${revision}.schema.json`), bytes); + entry.sha256 = createHash('sha256').update(bytes).digest('hex'); + entry.bytes = bytes.byteLength; + console.log(`[${revision}] ${entry.bytes} bytes, sha256 ${entry.sha256}`); + } + + manifest.source = { repository: SPEC_REPO, commit: sha }; + writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 4)}\n`, 'utf8'); + console.log(`Updated ${MANIFEST_PATH}`); +} + +main().catch((error: unknown) => { + console.error('Error:', error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/test/e2e/scenarios/raw-result-type.test.ts b/test/e2e/scenarios/raw-result-type.test.ts index 463bfccddf..35956810fe 100644 --- a/test/e2e/scenarios/raw-result-type.test.ts +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -1,12 +1,23 @@ /** - * Raw-first result discrimination through the full client path. + * Raw-first result discrimination through the full client path — ERA-SCOPED + * (Q1 increment 2: V-1 lives in the era codec's decodeResult, and the + * postures are ruled per era by Q1-SD3). * - * A raw relay server (no SDK Server involved) answers tools/call with an - * `input_required` body — the 2026-era multi-round-trip shape. The full - * client stack (Client → protocol funnel → transport) must surface the - * discriminated kind as a typed local error and never mask it into an - * empty-content success (the tools/call result schema defaults `content` to - * `[]`, which would otherwise swallow the body whole). + * A raw relay server (no SDK Server involved) answers tools/call with hand + * built bodies. The negotiated protocol version selects the wire era: + * + * - Negotiated 2026-07-28: `resultType` is the REQUIRED discriminator. An + * `input_required` body surfaces the discriminated kind as a typed local + * error (the multi-round-trip driver consumes it when it lands); an + * ABSENT `resultType` is a spec violation surfaced as a typed error + * naming it. + * - Negotiated legacy (2025 era): `resultType` is FOREIGN vocabulary — + * strip-on-lift (Q1-SD3 ii; a deliberate, ledgered change from the + * pre-split era-blind rejection — changeset: codec-split-wire-break). The + * stripped body then fails the (default-free) result schema loudly + * because it has no content. + * + * Either way the V-1 invariant holds: never an empty-content success. */ import { Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import type { JSONRPCRequest } from '@modelcontextprotocol/server'; @@ -27,6 +38,9 @@ const INPUT_REQUIRED_BODY = { requestState: 'opaque-state' }; +/** A complete-looking body that omits the (2026-required) resultType. */ +const ABSENT_RESULT_TYPE_BODY = { content: [{ type: 'text', text: 'looks complete' }] }; + function initializeResult(requestedVersion: string) { return { protocolVersion: requestedVersion, @@ -35,17 +49,19 @@ function initializeResult(requestedVersion: string) { }; } -/** Route a raw request to the relay's hand-built response body. */ -function respondTo(request: JSONRPCRequest): unknown { - if (request.method === 'initialize') { - const requested = (request.params as { protocolVersion?: string } | undefined)?.protocolVersion ?? LATEST_PROTOCOL_VERSION; - return initializeResult(requested); - } - if (request.method === 'tools/call') return INPUT_REQUIRED_BODY; - return {}; +function makeResponder(toolCallBody: unknown) { + return function respondTo(request: JSONRPCRequest): unknown { + if (request.method === 'initialize') { + const requested = (request.params as { protocolVersion?: string } | undefined)?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + return initializeResult(requested); + } + if (request.method === 'tools/call') return toolCallBody; + return {}; + }; } -async function connectInMemory(client: Client): Promise { +async function connectInMemory(client: Client, toolCallBody: unknown): Promise { + const respondTo = makeResponder(toolCallBody); const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); serverTx.onmessage = message => { const request = message as JSONRPCRequest; @@ -56,7 +72,8 @@ async function connectInMemory(client: Client): Promise { await client.connect(clientTx); } -async function connectStreamableHttp(client: Client): Promise { +async function connectStreamableHttp(client: Client, toolCallBody: unknown): Promise { + const respondTo = makeResponder(toolCallBody); // A hand HTTP handler (no SDK server): JSON responses, 202 for notifications. const fetchHandler = async (input: URL | string, init?: RequestInit): Promise => { const request = new Request(input, init); @@ -69,24 +86,76 @@ async function connectStreamableHttp(client: Client): Promise { await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: fetchHandler })); } +async function callToolOutcome(client: Client): Promise<{ resolved: unknown } | { rejected: unknown }> { + return client.callTool({ name: 'anything', arguments: {} }).then( + result => ({ resolved: result as unknown }), + error => ({ rejected: error as unknown }) + ); +} + verifies('typescript:client:raw-result-type-first', async ({ transport }: TestArgs) => { - const client = new Client({ name: 'raw-result-type-client', version: '0' }); - await (transport === 'inMemory' ? connectInMemory(client) : connectStreamableHttp(client)); + // ---- Legacy negotiation (the relay echoes the client's default offer, + // so this connection negotiates a legacy version → 2025 era). ---- + { + const client = new Client({ name: 'raw-result-type-client', version: '0' }); + await (transport === 'inMemory' + ? connectInMemory(client, INPUT_REQUIRED_BODY) + : connectStreamableHttp(client, INPUT_REQUIRED_BODY)); - try { - const outcome = await client.callTool({ name: 'anything', arguments: {} }).then( - result => ({ resolved: result as unknown }), - error => ({ rejected: error as unknown }) - ); + try { + const outcome = await callToolOutcome(client); + // Strip-on-lift (Q1-SD3 ii, ledgered): the foreign resultType is + // dropped; the body has no content, so validation fails LOUDLY. + // Never an empty-content success. + expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); + } finally { + await client.close(); + } + } + + // ---- Modern negotiation: the client opts into the draft revision, the + // relay echoes it back → 2026 era → V-1 discrimination in the codec. ---- + { + const client = new Client({ name: 'raw-result-type-client', version: '0' }, { supportedProtocolVersions: ['2026-07-28'] }); + await (transport === 'inMemory' + ? connectInMemory(client, INPUT_REQUIRED_BODY) + : connectStreamableHttp(client, INPUT_REQUIRED_BODY)); + + try { + const outcome = await callToolOutcome(client); + expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + const typed = rejection as SdkError; + expect(typed.code).toBe(SdkErrorCode.UnsupportedResultType); + expect(typed.data).toMatchObject({ resultType: 'input_required', method: 'tools/call' }); + } finally { + await client.close(); + } + } - // Never an empty-content success. - expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false); - const rejection = (outcome as { rejected: unknown }).rejected; - expect(rejection).toBeInstanceOf(SdkError); - const typed = rejection as SdkError; - expect(typed.code).toBe(SdkErrorCode.UnsupportedResultType); - expect(typed.data).toMatchObject({ resultType: 'input_required', method: 'tools/call' }); - } finally { - await client.close(); + // ---- Modern negotiation, absent resultType: the spec violation is + // surfaced as a typed error naming it (Q1-SD3 i — the absent⇒complete + // bridge applies only to earlier-revision servers). ---- + { + const client = new Client({ name: 'raw-result-type-client', version: '0' }, { supportedProtocolVersions: ['2026-07-28'] }); + await (transport === 'inMemory' + ? connectInMemory(client, ABSENT_RESULT_TYPE_BODY) + : connectStreamableHttp(client, ABSENT_RESULT_TYPE_BODY)); + + try { + const outcome = await callToolOutcome(client); + expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + const typed = rejection as SdkError; + expect(typed.code).toBe(SdkErrorCode.InvalidResult); + expect(String(typed.message)).toContain('missing required resultType'); + } finally { + await client.close(); + } } }); From 981488a2e0c584fa24376371e4c1bcc726fe0459 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:33:45 +0100 Subject: [PATCH 12/37] feat(client): opt-in 2026-07-28 version negotiation; era-aware server version lists (#2302) --- .changeset/add-version-negotiation-option.md | 10 + .changeset/node-forward-supported-versions.md | 6 + .changeset/wire-server-discover.md | 11 + docs/migration.md | 46 ++ packages/client/src/client/client.ts | 168 ++++- packages/client/src/client/probeClassifier.ts | 249 +++++++ packages/client/src/client/streamableHttp.ts | 23 + .../client/src/client/versionNegotiation.ts | 402 +++++++++++ packages/client/src/index.ts | 1 + .../client/bodyDerivedProbeHeaders.test.ts | 128 ++++ packages/client/test/client/discover.test.ts | 101 +++ .../test/client/probeClassifier.test.ts | 306 ++++++++ .../test/client/versionNegotiation.test.ts | 670 ++++++++++++++++++ packages/core/src/errors/sdkErrors.ts | 9 + packages/core/src/index.ts | 1 + packages/core/src/shared/protocol.ts | 70 +- packages/core/src/shared/protocolEras.ts | 41 ++ packages/core/src/types/schemas.ts | 2 + packages/core/src/types/types.ts | 9 +- packages/core/src/wire/bootstrap.ts | 35 +- .../core/src/wire/rev2025-11-25/registry.ts | 49 +- .../core/test/shared/protocolEras.test.ts | 41 ++ .../core/test/types/discoverWiring.test.ts | 54 ++ .../core/test/types/errorSurfacePins.test.ts | 1 + packages/core/test/wire/eraGates.test.ts | 37 + .../middleware/node/src/streamableHttp.ts | 11 + packages/server/src/server/server.ts | 67 +- .../server/classificationCarrierPin.test.ts | 131 ++++ packages/server/test/server/discover.test.ts | 211 ++++++ packages/server/test/server/server.test.ts | 19 +- test/e2e/requirements.ts | 7 +- test/e2e/scenarios/hosting-express.test.ts | 7 +- test/e2e/scenarios/protocol.test.ts | 63 +- test/e2e/scenarios/raw-result-type.test.ts | 28 +- test/e2e/scenarios/transport-raw.test.ts | 32 +- test/e2e/types.ts | 2 +- test/integration/test/client/client.test.ts | 41 +- .../test/client/discoverRoundtrip.test.ts | 170 +++++ .../test/client/versionNegotiation.test.ts | 288 ++++++++ 39 files changed, 3395 insertions(+), 152 deletions(-) create mode 100644 .changeset/add-version-negotiation-option.md create mode 100644 .changeset/node-forward-supported-versions.md create mode 100644 .changeset/wire-server-discover.md create mode 100644 packages/client/src/client/probeClassifier.ts create mode 100644 packages/client/src/client/versionNegotiation.ts create mode 100644 packages/client/test/client/bodyDerivedProbeHeaders.test.ts create mode 100644 packages/client/test/client/discover.test.ts create mode 100644 packages/client/test/client/probeClassifier.test.ts create mode 100644 packages/client/test/client/versionNegotiation.test.ts create mode 100644 packages/core/src/shared/protocolEras.ts create mode 100644 packages/core/test/shared/protocolEras.test.ts create mode 100644 packages/core/test/types/discoverWiring.test.ts create mode 100644 packages/server/test/server/classificationCarrierPin.test.ts create mode 100644 packages/server/test/server/discover.test.ts create mode 100644 test/integration/test/client/discoverRoundtrip.test.ts create mode 100644 test/integration/test/client/versionNegotiation.test.ts diff --git a/.changeset/add-version-negotiation-option.md b/.changeset/add-version-negotiation-option.md new file mode 100644 index 0000000000..1c81a2b294 --- /dev/null +++ b/.changeset/add-version-negotiation-option.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +--- + +Add opt-in protocol version negotiation on `ClientOptions.versionNegotiation`. The default is unchanged: without the option (or with `mode: 'legacy'`) the client performs today's 2025 connect sequence byte-identically. `mode: 'auto'` probes the server with `server/discover` at +connect time and conservatively falls back to the plain legacy `initialize` handshake on the same connection unless the outcome is definitive modern evidence; a network outage rejects with a typed connect error, and a probe timeout is transport-aware — on stdio it indicates +a legacy server and falls back to `initialize` on the same stream, on HTTP it rejects with a typed timeout error. +`mode: { pin: '' }` negotiates exactly the pinned modern revision with no fallback. Probe policy lives under `probe: { timeoutMs? }` — the probe inherits the standard request timeout. The probe's `MCP-Protocol-Version`/`Mcp-Method` headers derive from the probe +message body; the transport version slot is never touched during negotiation, so legacy-era traffic carries zero 2026 headers by construction. Adds the `SdkErrorCode.EraNegotiationFailed` code for negotiation-phase connect failures. diff --git a/.changeset/node-forward-supported-versions.md b/.changeset/node-forward-supported-versions.md new file mode 100644 index 0000000000..413f53fde4 --- /dev/null +++ b/.changeset/node-forward-supported-versions.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/node': patch +--- + +Forward `setSupportedProtocolVersions` from `NodeStreamableHTTPServerTransport` to the wrapped Web Standard transport. Previously a server's `supportedProtocolVersions` option never reached the Node adapter's `MCP-Protocol-Version` header validation, which silently kept +validating against the default version list. diff --git a/.changeset/wire-server-discover.md b/.changeset/wire-server-discover.md new file mode 100644 index 0000000000..b83b860e5e --- /dev/null +++ b/.changeset/wire-server-discover.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/client': minor +--- + +Wire `server/discover` (protocol revision 2026-07-28) into the typed request funnel and serve it era-aware. The request joins `ClientRequestSchema`/`ServerResultSchema`/`ResultTypeMap` (per-era availability stays with the wire registries: only the 2026-era registry serves +it), and `Client.discover()` issues it as a typed request on 2026-era connections. A `Server` whose `supportedProtocolVersions` list carries a modern (2026-07-28+) revision installs the `server/discover` handler, advertising ONLY its modern revisions and excluding the +listChanged/subscribe-class capabilities until the `subscriptions/listen` flow ships; servers with today's default list are unchanged and keep answering `-32601`. The `initialize` handshake is now era-aware in the other direction: its accept check and counter-offer consult +only the legacy subset of the supported versions — a 2026-era revision is never negotiated via `initialize` — so a 2025-era client can never be offered a 2026 version string; with the default list this is byte-identical to previous behavior. Serving the 2026 revision to +ordinary HTTP/stdio traffic arrives with an upcoming server-side entry point: today the negotiation surface is client-side, and `mode: 'auto'` falls back cleanly against current SDK servers. diff --git a/docs/migration.md b/docs/migration.md index 764203ec2b..67f24e19e6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -984,6 +984,52 @@ protocol.setRequestHandler( ## Enhancements +### Opt-in protocol version negotiation (2026-07-28 draft) + +The client can now negotiate the protocol era at connect time. This is **opt-in**: if you do nothing, `connect()` performs exactly the same 2025 `initialize` handshake as before, byte for byte. + +```typescript +import { Client } from '@modelcontextprotocol/client'; + +// Auto-negotiate: try the 2026-07-28 draft revision, fall back to the 2025 +// handshake automatically when the server is a 2025-era deployment. +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' } } +); +await client.connect(transport); + +client.getNegotiatedProtocolVersion(); // e.g. '2026-07-28' or '2025-11-25' +``` + +How the modes behave: + +- **absent / `mode: 'legacy'`** (default): today's behavior, unchanged. No probe, no new headers. +- **`mode: 'auto'`**: `connect()` first sends a single `server/discover` probe. A modern server answers it and no `initialize` is sent; a 2025-era server rejects it (deployed servers answer fast, e.g. `-32601` or a `400`), and the client falls back to the plain legacy + handshake **on the same connection** — byte-equivalent to a 2025 client, including the `initialize` body version and with zero 2026 headers. The probe costs one round trip against an old server and nothing else. +- **`mode: { pin: '2026-07-28' }`**: modern era at exactly that revision. No fallback — if the server does not offer the pinned version, `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). + +Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era, while a network outage rejects with a typed connect error (`SdkError` +with `EraNegotiationFailed`). A probe timeout is transport-aware, following the specification's backward-compatibility rules: on **stdio**, a server that does not answer the probe within the timeout is treated as a legacy server (some legacy servers never respond to unknown +pre-`initialize` requests at all) and the client falls back to `initialize` on the same stream; on **HTTP**, where a deployed server answers and silence means an outage, the timeout rejects with a typed `RequestTimeout` error — a dead HTTP server is never misreported as a +legacy server. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers and the legacy handshake sends none of them. + +Probe policy is configured under `versionNegotiation.probe`: + +```typescript +versionNegotiation: { + mode: 'auto', + probe: { + timeoutMs: 10_000 // default: the standard request timeout + } +} +``` + +On the server side, a `Server`/`McpServer` whose `supportedProtocolVersions` list includes a 2026-era revision installs a `server/discover` handler, advertising only its modern revisions; servers with the default version list are byte-identical to before (they keep +answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Note that serving the 2026 revision to ordinary HTTP/stdio traffic arrives with an upcoming server-side entry +point: today the negotiation surface is client-side, and `mode: 'auto'` falls back cleanly against current SDK servers. The client can also issue the request directly via `client.discover()` on a 2026-era connection — though a full typed round-trip against an SDK +server additionally needs the per-request envelope support that lands with that server entry — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. + ### Automatic JSON Schema validator selection by runtime The SDK now automatically selects the appropriate JSON Schema validator based on your runtime environment: diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 29710cbea4..a032a10ee4 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -9,6 +9,7 @@ import type { ClientRequest, CompleteRequest, CompleteResult, + DiscoverResult, EmptyResult, GetPromptRequest, GetPromptResult, @@ -46,19 +47,22 @@ import { codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - LATEST_PROTOCOL_VERSION, + DEFAULT_REQUEST_TIMEOUT_MSEC, + DiscoverResultSchema, + legacyProtocolVersions, ListChangedOptionsBaseSchema, mergeCapabilities, - negotiatedProtocolVersionOf, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode, - setNegotiatedProtocolVersion + SdkErrorCode } from '@modelcontextprotocol/core'; +import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js'; +import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation.js'; + /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. * @@ -149,6 +153,28 @@ export type ClientOptions = ProtocolOptions & { */ jsonSchemaValidator?: jsonSchemaValidator; + /** + * Opt-in protocol version negotiation (protocol revision 2026-07-28 and later). + * + * - absent or `mode: 'legacy'` — the plain 2025 connect sequence, byte-identical + * to today's behavior (no probe, no new headers). + * - `mode: 'auto'` — `connect()` probes the server with `server/discover` first: + * definitive modern evidence selects the modern era; definitive legacy signals + * (and anything unrecognized) fall back to the plain legacy `initialize` + * handshake on the same connection, byte-equivalent to a 2025 client. A + * network outage rejects with a typed connect error. A probe timeout is + * transport-aware: on stdio it indicates a legacy server (some legacy servers + * never answer unknown pre-`initialize` requests) and falls back to + * `initialize` on the same stream; on HTTP it rejects with a typed timeout + * error (silence on a deployed server is an outage, not a legacy signal). + * - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision; + * no probe-and-fallback: anything else fails loudly. + * + * Probe policy lives under `probe: { timeoutMs? }`; the probe inherits the + * client's standard request timeout unless overridden. + */ + versionNegotiation?: VersionNegotiationOptions; + /** * Configure handlers for list changed notifications (tools, prompts, resources). * @@ -222,6 +248,8 @@ export class Client extends Protocol { private _listChangedDebounceTimers: Map> = new Map(); private _pendingListChangedConfig?: ListChangedHandlers; private _enforceStrictCapabilities: boolean; + private _versionNegotiation?: VersionNegotiationOptions; + private _supportedProtocolVersionsOption?: string[]; /** * Initializes this client with the given name and version information. @@ -234,6 +262,8 @@ export class Client extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; + this._versionNegotiation = options?.versionNegotiation; + this._supportedProtocolVersionsOption = options?.supportedProtocolVersions; // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -300,7 +330,7 @@ export class Client extends Protocol { // Era-exact validation: the schemas are resolved from the // instance era at dispatch time (the era gate guarantees the // method exists on the serving era before we get here). - const codec = codecForVersion(negotiatedProtocolVersionOf(this)); + const codec = codecForVersion(this._negotiatedProtocolVersion); const elicitRequestSchema = codec.requestSchema('elicitation/create'); // The era registry entry IS the plain ElicitResult schema // (the result map is aligned to the typed map — no widened @@ -363,7 +393,7 @@ export class Client extends Protocol { if (method === 'sampling/createMessage') { return async (request, ctx) => { // Era-exact validation via the instance era (see above). - const codec = codecForVersion(negotiatedProtocolVersionOf(this)); + const codec = codecForVersion(this._negotiatedProtocolVersion); const samplingRequestSchema = codec.requestSchema('sampling/createMessage'); if (!samplingRequestSchema) { throw new ProtocolError( @@ -439,12 +469,17 @@ export class Client extends Protocol { * ``` */ override async connect(transport: Transport, options?: RequestOptions): Promise { + const negotiation = resolveVersionNegotiation(this._versionNegotiation, this._supportedProtocolVersionsOption); + if (negotiation.kind !== 'legacy') { + return this._connectNegotiated(transport, negotiation, options); + } + // Plain legacy connect — the pinned 2025 sequence, byte-untouched. await super.connect(transport); // When transport sessionId is already set this means we are trying to reconnect. // Restore the protocol version negotiated during the original initialize handshake // so HTTP transports include the required mcp-protocol-version header, but skip re-init. if (transport.sessionId !== undefined) { - const negotiatedProtocolVersion = negotiatedProtocolVersionOf(this); + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; if (negotiatedProtocolVersion !== undefined) { // Resuming keeps the original negotiation: the instance still // holds the negotiated version (and with it the wire era) — @@ -461,13 +496,34 @@ export class Client extends Protocol { // Without this, an instance that once negotiated a modern era could // never re-run a fresh handshake: `initialize` is physically absent // from the modern registry. (The resume branch above keeps it instead.) - setNegotiatedProtocolVersion(this, undefined); + this._negotiatedProtocolVersion = undefined; + await this._legacyHandshake(transport, options); + } + + /** + * The 2025 `initialize` handshake — the body of the plain legacy connect and + * the `'auto'`-mode fallback path (same connection, same `initialize` body, + * zero 2026 headers). Callers clear the negotiated protocol version before + * the handshake; its completion sets the negotiated (legacy) version. + */ + private async _legacyHandshake(transport: Transport, options?: RequestOptions): Promise { + // initialize is a legacy-era handshake: only the legacy subset of the + // supported versions is ever offered or accepted here — a 2026-era + // revision is negotiated exclusively via server/discover. + const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); try { + const offeredVersion = legacyVersions[0]; + if (offeredVersion === undefined) { + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + 'Cannot run the initialize handshake: supportedProtocolVersions contains no pre-2026-07-28 protocol version' + ); + } const result = await this.request( { method: 'initialize', params: { - protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, + protocolVersion: offeredVersion, capabilities: this._capabilities, clientInfo: this._clientInfo } @@ -479,7 +535,7 @@ export class Client extends Protocol { throw new Error(`Server sent invalid initialize result: ${result}`); } - if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { + if (!legacyVersions.includes(result.protocolVersion)) { throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); } @@ -503,7 +559,7 @@ export class Client extends Protocol { // Q1-SD1). Set AFTER the initialized notification: the initialize // EXCHANGE is the legacy handshake by definition and completes on // that era. - setNegotiatedProtocolVersion(this, result.protocolVersion); + this._negotiatedProtocolVersion = result.protocolVersion; // Set up list changed handlers now that we know server capabilities if (this._pendingListChangedConfig) { @@ -517,6 +573,73 @@ export class Client extends Protocol { } } + /** + * Negotiated connect (mode `'auto'` or `{ pin }`): probe with `server/discover` + * before the Protocol machinery attaches, then either establish the modern era + * or perform the plain legacy handshake on the same connection. + */ + private async _connectNegotiated( + transport: Transport, + negotiation: Extract, + options?: RequestOptions + ): Promise { + // Session-resuming reconnect: restore the previously negotiated version, + // never re-probe mid-session. + if (transport.sessionId !== undefined) { + await super.connect(transport); + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { + transport.setProtocolVersion(negotiatedProtocolVersion); + } + return; + } + + // Fresh connect: stale connection state must not survive into a new + // negotiation — every fresh negotiated connect re-runs the probe. + this._negotiatedProtocolVersion = undefined; + + let result: Awaited>; + try { + result = await negotiateEra(negotiation, { + transport, + clientInfo: this._clientInfo, + capabilities: this._capabilities, + environment: detectProbeEnvironment(), + transportKind: detectProbeTransportKind(transport), + defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC + }); + } catch (error) { + // Typed connect error — close the channel like a failed initialize does. + await transport.close().catch(() => {}); + throw error; + } + + await super.connect(transport); + + if (result.era === 'legacy') { + // Conservative fallback: the plain legacy handshake on the SAME + // connection (the probe never touched the transport version slot). + await this._legacyHandshake(transport, options); + return; + } + + this._serverCapabilities = result.discover.capabilities; + this._serverVersion = result.discover.serverInfo; + this._instructions = result.discover.instructions; + // Modern selection: the same connection state the legacy handshake completion sets. + this._negotiatedProtocolVersion = result.version; + // The single setProtocolVersion call site on this path, mirroring the legacy path after initialize. + if (transport.setProtocolVersion) { + transport.setProtocolVersion(result.version); + } + // The modern era has no notifications/initialized; list-changed handlers + // are configured straight from the advertised capabilities. + if (this._pendingListChangedConfig) { + this._setupListChangedHandlers(this._pendingListChangedConfig); + this._pendingListChangedConfig = undefined; + } + } + /** * After initialization has completed, this will be populated with the server's reported capabilities. */ @@ -537,7 +660,7 @@ export class Client extends Protocol { * value to the new transport so it continues sending the required `mcp-protocol-version` header. */ getNegotiatedProtocolVersion(): string | undefined { - return negotiatedProtocolVersionOf(this); + return this._negotiatedProtocolVersion; } /** @@ -605,6 +728,12 @@ export class Client extends Protocol { break; } + case 'server/discover': { + // No specific capability required for discover (protocol revision + // 2026-07-28; servers on that revision MUST implement it) + break; + } + case 'ping': { // No specific capability required for ping break; @@ -690,6 +819,21 @@ export class Client extends Protocol { return this.request({ method: 'ping' }, options); } + /** + * Asks the server to advertise its supported protocol versions, capabilities, + * and implementation info (`server/discover`, protocol revision 2026-07-28). + * + * Servers on the 2026-07-28 revision MUST implement this; the connect-time + * version negotiation issues it automatically. The method exists only on + * the 2026-07-28 era: on a connection negotiated to a 2025-era version it + * is rejected locally with a typed `SdkError` + * (`MethodNotSupportedByProtocolVersion`) before anything reaches the + * transport. + */ + async discover(options?: RequestOptions): Promise { + return this._requestWithSchema({ method: 'server/discover' }, DiscoverResultSchema, options); + } + /** Requests argument autocompletion suggestions from the server for a prompt or resource. */ async complete(params: CompleteRequest['params'], options?: RequestOptions): Promise { return this.request({ method: 'completion/complete', params }, options); diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts new file mode 100644 index 0000000000..950174fdc6 --- /dev/null +++ b/packages/client/src/client/probeClassifier.ts @@ -0,0 +1,249 @@ +/** + * Probe outcome classifier (pure module): maps the outcome of the connect-time + * `server/discover` probe onto one of four verdicts — modern era, the + * spec-mandated `-32004` corrective continuation, legacy fallback (the plain + * 2025 `initialize` handshake on the same connection), or a typed connect error. + * + * The classifier is deliberately conservative: anything it does not positively + * recognize as modern resolves to the legacy fallback, and a network outage is a + * typed connect error, never an era verdict. The verdicts apply to the + * negotiation phase only — an established modern connection is never silently + * demoted to `initialize` by a later failure. + */ +import type { DiscoverResult } from '@modelcontextprotocol/core'; +import { + DiscoverResultSchema, + modernProtocolVersions, + SdkError, + SdkErrorCode, + UnsupportedProtocolVersionError +} from '@modelcontextprotocol/core'; + +/** + * The runtime environment the probe executed in. Only consulted for the + * network-failure row: a browser CORS-preflight rejection is treated as a + * legacy signal, while in Node a network failure stays a typed connect error. + */ +export type ProbeEnvironment = 'node' | 'browser'; + +/** + * The transport class the probe ran on. Only consulted for the timeout row: a + * stdio probe that times out signals a legacy server, while an HTTP timeout + * stays a typed error. Anything that is not the stdio child-process transport + * is treated like HTTP. + */ +export type ProbeTransportKind = 'stdio' | 'http'; + +/** + * A normalized probe outcome, produced by the connect-time wiring from the raw + * transport exchange. + */ +export type ProbeOutcome = + | { kind: 'result'; result: unknown } + /** Answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */ + | { kind: 'rpc-error'; code: number; message: string; data?: unknown } + /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */ + | { kind: 'http-error'; status: number; body?: string } + | { kind: 'network-error'; error: unknown } + /** No response arrived within the probe timeout. */ + | { kind: 'timeout'; timeoutMs: number }; + +export interface ProbeClassifierContext { + /** Modern-era versions this client can negotiate, in preference order (never empty). */ + clientModernVersions: readonly string[]; + /** The version the probe carried in its `_meta` envelope (used to synthesize `data.requested` on typed errors). */ + requestedVersion: string; + /** + * Whether a legacy `initialize` fallback is possible — `false` for a + * modern-only client and for `pin` mode, where rows that would otherwise + * fall back yield a typed `UnsupportedProtocolVersionError` instead. + */ + fallbackAvailable: boolean; + /** See {@linkcode ProbeEnvironment}. */ + environment: ProbeEnvironment; + /** See {@linkcode ProbeTransportKind}. */ + transportKind: ProbeTransportKind; +} + +export type ProbeVerdict = + /** Definitive modern evidence: select `version` and continue without `initialize`. */ + | { kind: 'modern'; version: string; discover: DiscoverResult } + /** + * `-32004` with a mutual modern version: re-send the probe at `version`. + * Spec-mandated select-and-continue — the caller runs it exactly once and + * arms a loop guard on the second rejection, throwing `error`. + */ + | { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError } + /** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */ + | { kind: 'legacy' } + /** Typed connect error — never converted to an era verdict. */ + | { kind: 'error'; error: Error }; + +/** The `-32004` UnsupportedProtocolVersion protocol error code (negotiation-phase recognition). */ +const UNSUPPORTED_PROTOCOL_VERSION = -32_004; +/** + * Deliberately not probe-recognized in either direction: deployed servers + * overload `-32001` and the error-code ladder for these cells is still being + * derived upstream, so both fall into the conservative legacy default. + */ +const NOT_PROBE_RECOGNIZED = new Set([-32_001, -32_003]); + +/** + * Classify a single probe outcome. Pure: no I/O, no state — loop-guard and + * retry state live in the caller. + */ +export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassifierContext): ProbeVerdict { + switch (outcome.kind) { + case 'result': { + return classifyResult(outcome.result, context); + } + case 'rpc-error': { + return classifyRpcError(outcome, context); + } + case 'http-error': { + return classifyHttpError(outcome, context); + } + case 'network-error': { + return classifyNetworkError(outcome.error, context); + } + case 'timeout': { + if (context.transportKind === 'stdio') { + // Per the stdio transport's backward-compatibility rule, a probe + // nobody answers within the timeout indicates a legacy server — + // fall back to `initialize` on the same stream. + return { kind: 'legacy' }; + } + // On HTTP a deployed server answers, so silence is an outage, not a + // legacy signal: keep the typed timeout error (the compatibility + // matrix keys the HTTP legacy signal to a 4xx, never to silence). + return { + kind: 'error', + error: new SdkError(SdkErrorCode.RequestTimeout, `Version negotiation probe timed out after ${outcome.timeoutMs}ms`, { + timeout: outcome.timeoutMs + }) + }; + } + } +} + +function classifyResult(result: unknown, context: ProbeClassifierContext): ProbeVerdict { + const parsed = DiscoverResultSchema.safeParse(result); + if (!parsed.success) { + // Unrecognized result shape: not modern evidence — conservative legacy fallback. + return { kind: 'legacy' }; + } + const supportedVersions = parsed.data.supportedVersions; + const overlap = context.clientModernVersions.find(version => supportedVersions.includes(version)); + if (overlap !== undefined) { + return { kind: 'modern', version: overlap, discover: parsed.data }; + } + // A DiscoverResult with no overlap still drives era selection: initialize on + // the same connection when fallback is possible, otherwise a typed error. + if (context.fallbackAvailable) { + return { kind: 'legacy' }; + } + return { + kind: 'error', + error: new UnsupportedProtocolVersionError({ supported: [...supportedVersions], requested: context.requestedVersion }) + }; +} + +function classifyRpcError(outcome: { code: number; message: string; data?: unknown }, context: ProbeClassifierContext): ProbeVerdict { + const { code, message, data } = outcome; + + if (code === UNSUPPORTED_PROTOCOL_VERSION) { + const supported = parseSupportedList(data); + if (supported === undefined) { + // -32004 without a valid data.supported list is not actionable modern evidence. + return { kind: 'legacy' }; + } + const requested = parseRequested(data) ?? context.requestedVersion; + const error = new UnsupportedProtocolVersionError({ supported, requested }, message); + const supportedModern = modernProtocolVersions(supported); + const mutual = context.clientModernVersions.find(version => supportedModern.includes(version)); + if (mutual !== undefined) { + // Mutual modern version: spec-mandated select-and-continue — never + // fall back to initialize here. + return { kind: 'corrective', version: mutual, error }; + } + if (supportedModern.length > 0) { + // Disjoint-but-modern list: typed error, never initialize. + return { kind: 'error', error }; + } + // Legacy-only list: definitive legacy signal (typed error for a modern-only client). + return context.fallbackAvailable ? { kind: 'legacy' } : { kind: 'error', error }; + } + + if (NOT_PROBE_RECOGNIZED.has(code)) { + return { kind: 'legacy' }; + } + + // Everything else — -32601, the deployed -32000 literals/free-text, code 0, + // any unrecognized code — is a legacy signal or the conservative default. + return { kind: 'legacy' }; +} + +function classifyHttpError(outcome: { status: number; body?: string }, context: ProbeClassifierContext): ProbeVerdict { + // HTTP-rejected probes carry their JSON-RPC error in the response body — classify it like an in-band error. + const rpcError = parseJsonRpcErrorBody(outcome.body); + if (rpcError !== undefined) { + return classifyRpcError(rpcError, context); + } + // Unparseable or unrecognized HTTP rejection: conservative legacy fallback. + return { kind: 'legacy' }; +} + +function classifyNetworkError(error: unknown, context: ProbeClassifierContext): ProbeVerdict { + if (context.environment === 'browser' && isOpaqueFetchTypeError(error)) { + // A browser CORS-preflight rejection against a deployed 2025 server is an + // opaque TypeError; the legacy fallback carries no custom headers (no + // preflight), so it can proceed where the probe could not. + return { kind: 'legacy' }; + } + return { + kind: 'error', + error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { + cause: error + }) + }; +} + +function isOpaqueFetchTypeError(error: unknown): boolean { + // Cross-realm safe: a bundled or sandboxed fetch may not share this realm's TypeError identity. + return error instanceof TypeError || (error instanceof Error && error.name === 'TypeError'); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function parseSupportedList(data: unknown): string[] | undefined { + if (typeof data !== 'object' || data === null) return undefined; + const supported = (data as { supported?: unknown }).supported; + if (!Array.isArray(supported) || supported.length === 0 || !supported.every(v => typeof v === 'string')) { + return undefined; + } + return supported as string[]; +} + +function parseRequested(data: unknown): string | undefined { + if (typeof data !== 'object' || data === null) return undefined; + const requested = (data as { requested?: unknown }).requested; + return typeof requested === 'string' ? requested : undefined; +} + +function parseJsonRpcErrorBody(body: string | undefined): { code: number; message: string; data?: unknown } | undefined { + if (body === undefined || body === '') return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const error = (parsed as { error?: unknown }).error; + if (typeof error !== 'object' || error === null) return undefined; + const { code, message, data } = error as { code?: unknown; message?: unknown; data?: unknown }; + if (typeof code !== 'number') return undefined; + return { code, message: typeof message === 'string' ? message : '', data }; +} diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 3b8ddafe5a..5dea9a7cc5 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -9,6 +9,7 @@ import { isJSONRPCResultResponse, JSONRPCMessageSchema, normalizeHeaders, + PROTOCOL_VERSION_META_KEY, SdkError, SdkErrorCode, SdkHttpError @@ -231,6 +232,27 @@ export class StreamableHTTPClientTransport implements Transport { }); } + /** + * Body-derived per-request headers: when an outgoing request carries a + * protocol-version claim in its `_meta` envelope (the version negotiation + * probe is the first such sender), `MCP-Protocol-Version` and `Mcp-Method` + * derive from the message itself. The connection-level version slot is + * neither consulted nor mutated; messages without an envelope claim are + * untouched, so no 2026 header can appear on a legacy exchange. + */ + private _applyBodyDerivedHeaders(headers: Headers, message: JSONRPCMessage | JSONRPCMessage[]): void { + if (Array.isArray(message) || !isJSONRPCRequest(message)) { + return; + } + const meta = (message.params as { _meta?: Record } | undefined)?._meta; + const envelopeVersion = meta?.[PROTOCOL_VERSION_META_KEY]; + if (typeof envelopeVersion !== 'string') { + return; + } + headers.set('mcp-protocol-version', envelopeVersion); + headers.set('mcp-method', message.method); + } + private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { const { resumptionToken } = options; @@ -541,6 +563,7 @@ export class StreamableHTTPClientTransport implements Transport { } const headers = await this._commonHeaders(); + this._applyBodyDerivedHeaders(headers, message); headers.set('content-type', 'application/json'); const userAccept = headers.get('accept'); const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream']; diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts new file mode 100644 index 0000000000..f4b80511ca --- /dev/null +++ b/packages/client/src/client/versionNegotiation.ts @@ -0,0 +1,402 @@ +/** + * Connect-time protocol version negotiation (opt-in via + * `ClientOptions.versionNegotiation`): the option surface, the probe window (a + * raw transport exchange run before the Protocol machinery attaches), and the + * negotiation engine driving the pure {@linkcode classifyProbeOutcome} classifier. + * + * Invariants: the probe uses string ids and consumes no Protocol message ids, so + * a legacy fallback's `initialize` is byte-equivalent to a plain legacy connect; + * the transport's protocol-version slot is never mutated during negotiation + * (probe headers derive from the probe message body) and is set exactly once + * after a modern resolution; while the probe window is open, inbound messages + * that are not the probe response are dropped with zero bytes written back. + */ +import type { ClientCapabilities, DiscoverResult, Implementation, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + isModernProtocolVersion, + legacyProtocolVersions, + modernProtocolVersions, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + SdkHttpError, + SUPPORTED_MODERN_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; + +import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier.js'; +import { classifyProbeOutcome } from './probeClassifier.js'; + +/** + * Probe policy for `'auto'` and pinned negotiation modes. + * + * There is no special probe timeout opinion: the probe inherits the client's + * STANDARD request timeout unless `timeoutMs` overrides it. + */ +export interface VersionNegotiationProbeOptions { + /** + * Timeout for the probe exchange, in milliseconds. + * + * The timeout verdict is transport-aware: on stdio, a probe that gets no + * response within the timeout indicates a legacy server and falls back to + * the `initialize` handshake on the same stream; on HTTP, where a deployed + * server answers and silence means an outage, `connect()` rejects with the + * standard typed timeout error instead. + * + * @default the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`) + */ + timeoutMs?: number; +} + +/** + * Negotiation mode: + * + * - `'legacy'` — no negotiation: the plain 2025 connect sequence, byte-identical + * to a client without this option. + * - `'auto'` — probe with `server/discover` at connect; conservative fallback to + * the plain legacy `initialize` handshake on the same connection unless the + * outcome is definitive modern evidence. Network outage rejects with a typed + * connect error; a probe timeout falls back to `initialize` on stdio (a silent + * server on a local pipe is a legacy server) and rejects with a typed timeout + * error on HTTP (silence there is an outage). + * - `{ pin: '' }` — modern era at exactly the pinned revision: the + * connect-time `server/discover` must offer it. No fallback — anything else + * fails loudly with a typed error. + */ +export type VersionNegotiationMode = 'legacy' | 'auto' | { pin: string }; + +/** + * Opt-in protocol version negotiation, configured on + * `ClientOptions.versionNegotiation`. + */ +export interface VersionNegotiationOptions { + /** + * @default 'legacy' + */ + mode?: VersionNegotiationMode; + + /** + * Probe timeout/retry policy (only consulted by the probing modes). + */ + probe?: VersionNegotiationProbeOptions; +} + +/** + * The default mode when `versionNegotiation` (or its `mode`) is absent; + * changing the default later is a flip of this single line. + */ +const DEFAULT_VERSION_NEGOTIATION_MODE: VersionNegotiationMode = 'legacy'; + +/** A fully resolved negotiation plan for one `connect()` call. */ +export type ResolvedVersionNegotiation = + | { kind: 'legacy' } + | { + kind: 'auto'; + /** Modern versions this client offers, in preference order (never empty). */ + modernVersions: string[]; + /** Whether this client can fall back to the legacy `initialize` handshake. */ + fallbackAvailable: boolean; + probe: VersionNegotiationProbeOptions; + } + | { kind: 'pin'; version: string; probe: VersionNegotiationProbeOptions }; + +/** + * Resolve the negotiation options into a per-connect plan. The raw (not + * defaulted) `supportedProtocolVersions` option supplies the modern offer list; + * a list without any legacy version makes this a modern-only client (no fallback). + */ +export function resolveVersionNegotiation( + options: VersionNegotiationOptions | undefined, + supportedProtocolVersionsOption: readonly string[] | undefined +): ResolvedVersionNegotiation { + const mode = options?.mode ?? DEFAULT_VERSION_NEGOTIATION_MODE; + if (mode === 'legacy') { + return { kind: 'legacy' }; + } + const probe = options?.probe ?? {}; + if (typeof mode === 'object') { + if (!isModernProtocolVersion(mode.pin)) { + throw new TypeError( + `versionNegotiation: { pin: '${mode.pin}' } is not a modern protocol revision — ` + + `pinning is for 2026-07-28 and later; omit versionNegotiation (or use mode: 'legacy') for 2025-era servers.` + ); + } + return { kind: 'pin', version: mode.pin, probe }; + } + const explicitModern = supportedProtocolVersionsOption ? modernProtocolVersions(supportedProtocolVersionsOption) : []; + const modernVersions = explicitModern.length > 0 ? explicitModern : [...SUPPORTED_MODERN_PROTOCOL_VERSIONS]; + const fallbackAvailable = supportedProtocolVersionsOption ? legacyProtocolVersions(supportedProtocolVersionsOption).length > 0 : true; + return { kind: 'auto', modernVersions, fallbackAvailable, probe }; +} + +/** Detect the probe environment for the network-failure row — see {@linkcode ProbeEnvironment}. */ +export function detectProbeEnvironment(): ProbeEnvironment { + const g = globalThis as { window?: unknown; document?: unknown }; + return g.window !== undefined && g.document !== undefined ? 'browser' : 'node'; +} + +/** + * Detect the transport class for the transport-aware timeout verdict (see + * {@linkcode ProbeTransportKind}). The stdio child-process transport is + * recognized structurally (`stderr`/`pid` accessors, no `instanceof` — safe + * across bundles); everything else is treated like HTTP. + */ +export function detectProbeTransportKind(transport: Transport): ProbeTransportKind { + return 'stderr' in transport && 'pid' in transport ? 'stdio' : 'http'; +} + +/** Raw reply from one probe exchange, before normalization. */ +type RawProbeReply = + | { kind: 'response'; result?: unknown; error?: { code: number; message: string; data?: unknown } } + | { kind: 'send-error'; error: unknown } + | { kind: 'closed' } + | { kind: 'timeout' }; + +/** + * Temporary ownership of a raw transport for the negotiation exchange, before + * the Protocol machinery attaches. `open()` installs the window's handlers and + * starts the transport; `release()` detaches them and arms a one-shot `start()` + * pass-through so the subsequent Protocol connect (which always starts its + * transport) takes over the already-started channel without a double-start error. + */ +class ProbeWindow { + private _pending: { id: string; resolve: (reply: RawProbeReply) => void } | undefined; + private _probeCounter = 0; + + private constructor(private readonly _transport: Transport) {} + + static async open(transport: Transport): Promise { + const window = new ProbeWindow(transport); + transport.onmessage = message => { + const pending = window._pending; + if ( + pending !== undefined && + (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) && + message.id === pending.id + ) { + window._pending = undefined; + if (isJSONRPCResultResponse(message)) { + pending.resolve({ kind: 'response', result: message.result }); + } else { + pending.resolve({ kind: 'response', error: message.error }); + } + return; + } + // Probe-window guard: drop everything else with zero bytes written back (see module doc). + }; + transport.onerror = () => { + // Out-of-band transport errors are not necessarily fatal; the probe + // resolves via a send failure, the close signal, or the timeout. + }; + transport.onclose = () => { + const pending = window._pending; + if (pending !== undefined) { + window._pending = undefined; + pending.resolve({ kind: 'closed' }); + } + }; + await transport.start(); + return window; + } + + /** + * Send one probe request and await its reply. Probe ids are strings, so they + * never collide with Protocol's numeric ids (e.g. on a shared stdio pipe). + */ + async exchange(buildRequest: (id: string) => JSONRPCRequest, timeoutMs: number): Promise { + const id = `server-discover-probe-${++this._probeCounter}`; + return new Promise(resolve => { + let settled = false; + const settle = (reply: RawProbeReply) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (this._pending?.id === id) { + this._pending = undefined; + } + resolve(reply); + }; + const timer = setTimeout(() => settle({ kind: 'timeout' }), timeoutMs); + this._pending = { id, resolve: settle }; + this._transport.send(buildRequest(id)).catch((error: unknown) => settle({ kind: 'send-error', error })); + }); + } + + /** Detach the window's handlers, leaving the transport's own `start` untouched. */ + detach(): void { + this._pending = undefined; + this._transport.onmessage = undefined; + this._transport.onerror = undefined; + this._transport.onclose = undefined; + } + + /** Detach the handlers and arm the one-shot `start()` pass-through for the `Protocol.connect()` handover. */ + release(): void { + this.detach(); + const transport = this._transport; + const originalStart = transport.start.bind(transport); + let armed = true; + transport.start = async (): Promise => { + if (armed) { + armed = false; + transport.start = originalStart; + return; + } + return originalStart(); + }; + } +} + +/** Build the probe request: `server/discover` carrying the full per-request `_meta` envelope. */ +export function buildProbeRequest( + id: string, + protocolVersion: string, + clientInfo: Implementation, + capabilities: ClientCapabilities +): JSONRPCRequest { + return { + jsonrpc: '2.0', + id, + method: 'server/discover', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: protocolVersion, + [CLIENT_INFO_META_KEY]: clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: capabilities + } + } + }; +} + +function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { + switch (reply.kind) { + case 'response': { + return reply.error === undefined ? { kind: 'result', result: reply.result } : { kind: 'rpc-error', ...reply.error }; + } + case 'send-error': { + const error = reply.error; + if (error instanceof SdkHttpError) { + const text = (error.data as { text?: unknown } | undefined)?.text; + return { kind: 'http-error', status: error.data.status, body: typeof text === 'string' ? text : undefined }; + } + if (error instanceof Error && error.name === 'UnauthorizedError') { + // Auth-gated server: not era evidence — the conservative legacy + // fallback re-runs the auth flow through the plain connect path. + return { kind: 'http-error', status: 401 }; + } + return { kind: 'network-error', error }; + } + case 'closed': { + return { kind: 'network-error', error: new Error('Connection closed during the version negotiation probe') }; + } + case 'timeout': { + return { kind: 'timeout', timeoutMs }; + } + } +} + +export interface NegotiationDeps { + transport: Transport; + clientInfo: Implementation; + capabilities: ClientCapabilities; + environment: ProbeEnvironment; + /** The transport class, for the transport-aware timeout verdict (see {@linkcode ProbeTransportKind}). */ + transportKind: ProbeTransportKind; + /** The standard request timeout for this connect (probe inherits it unless `probe.timeoutMs` overrides). */ + defaultTimeoutMs: number; +} + +export type NegotiationResult = { era: 'modern'; version: string; discover: DiscoverResult } | { era: 'legacy' }; + +/** + * Run the negotiation probe state machine on a raw (not yet Protocol-connected) + * transport. Resolves with the negotiated era; throws typed connect errors. On + * return the probe window has been released: the transport is started, + * handler-free, and ready for `Protocol.connect()` handover. On throw the + * window is detached and the transport's `start` is left untouched. + */ +export async function negotiateEra( + negotiation: Extract, + deps: NegotiationDeps +): Promise { + const timeoutMs = negotiation.probe.timeoutMs ?? deps.defaultTimeoutMs; + const clientModernVersions = negotiation.kind === 'pin' ? [negotiation.version] : negotiation.modernVersions; + const fallbackAvailable = negotiation.kind === 'auto' && negotiation.fallbackAvailable; + + const window = await ProbeWindow.open(deps.transport); + + const probe = async (): Promise => { + let requestedVersion = clientModernVersions[0]!; + // The -32004 corrective continuation runs exactly once (even when the + // mutual version equals the just-rejected one); the loop guard arms on + // the second rejection. + let correctiveUsed = false; + for (;;) { + const reply = await window.exchange( + id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), + timeoutMs + ); + + const outcome = normalizeReply(reply, timeoutMs); + const verdict: ProbeVerdict = classifyProbeOutcome(outcome, { + clientModernVersions, + requestedVersion, + fallbackAvailable, + environment: deps.environment, + transportKind: deps.transportKind + }); + + switch (verdict.kind) { + case 'modern': { + return { era: 'modern', version: verdict.version, discover: verdict.discover }; + } + case 'corrective': { + if (correctiveUsed) { + // Second rejection: loop guard. + throw verdict.error; + } + correctiveUsed = true; + requestedVersion = verdict.version; + continue; + } + case 'legacy': { + if (negotiation.kind === 'pin') { + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` + + `via server/discover (no fallback in pin mode)` + ); + } + if (!negotiation.fallbackAvailable) { + // Modern-only client: the legacy initialize fallback is + // unavailable and must never carry a 2026-era version string. + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + 'Version negotiation failed: the server gave no modern evidence and this client supports no ' + + 'pre-2026-07-28 protocol version to fall back to' + ); + } + return { era: 'legacy' }; + } + case 'error': { + throw verdict.error; + } + } + } + }; + + let result: NegotiationResult; + try { + result = await probe(); + } catch (error) { + // A failed negotiation leaves the transport exactly as it found it: + // handlers detached, original start untouched (no pass-through armed). + window.detach(); + throw error; + } + window.release(); + return result; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8a08e8fd79..42fc132c2a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -61,6 +61,7 @@ export type { LoggingOptions, Middleware, RequestLogger } from './client/middlew export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware.js'; export type { SSEClientTransportOptions } from './client/sse.js'; export { SSEClientTransport, SseError } from './client/sse.js'; +export type { VersionNegotiationMode, VersionNegotiationOptions, VersionNegotiationProbeOptions } from './client/versionNegotiation.js'; // StdioClientTransport, getDefaultEnvironment, DEFAULT_INHERITED_ENV_VARS, StdioServerParameters are exported from // the './stdio' subpath to keep the root entry free of process-spawning runtime dependencies (child_process, cross-spawn). export type { diff --git a/packages/client/test/client/bodyDerivedProbeHeaders.test.ts b/packages/client/test/client/bodyDerivedProbeHeaders.test.ts new file mode 100644 index 0000000000..de886f61e0 --- /dev/null +++ b/packages/client/test/client/bodyDerivedProbeHeaders.test.ts @@ -0,0 +1,128 @@ +/** + * Body-derived per-request headers on the streamable HTTP client transport: + * when a single outgoing request carries the 2026-07-28 protocol-version claim + * in its `_meta` envelope (the negotiation probe is the first such sender), the + * `MCP-Protocol-Version` and `Mcp-Method` headers derive from the message + * itself. The connection-level version slot is never consulted or mutated for + * those sends, and envelope-less (2025-era) traffic gets no new headers. + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; + +describe('body-derived probe headers', () => { + let transport: StreamableHTTPClientTransport; + let fetchSpy: ReturnType; + + const okJson = (body: unknown) => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve(body) + }); + + beforeEach(async () => { + fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + await transport.start(); + }); + + afterEach(async () => { + await transport.close().catch(() => {}); + vi.restoreAllMocks(); + }); + + const probeRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 'server-discover-probe-1', + method: 'server/discover', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'c', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } + }; + + const sentHeaders = (): Headers => { + const init = fetchSpy.mock.calls.at(-1)?.[1] as RequestInit; + return init.headers as Headers; + }; + + it('derives MCP-Protocol-Version and Mcp-Method from the probe message body', async () => { + fetchSpy.mockResolvedValueOnce( + okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } }) + ); + + await transport.send(probeRequest); + + const headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBe('2026-07-28'); + expect(headers.get('mcp-method')).toBe('server/discover'); + }); + + it('never mutates the transport version slot for body-derived sends', async () => { + fetchSpy.mockResolvedValueOnce( + okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } }) + ); + + await transport.send(probeRequest); + expect(transport.protocolVersion).toBeUndefined(); + + // A follow-up envelope-less message gets no version header at all — the + // slot is still unset; nothing leaked from the probe. + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 0, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 0, method: 'ping', params: {} }); + + const headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBeNull(); + expect(headers.get('mcp-method')).toBeNull(); + }); + + it('envelope-less (2025-era) requests are untouched: no 2026 headers, slot-driven behavior unchanged', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 1, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }); + + let headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBeNull(); + expect(headers.get('mcp-method')).toBeNull(); + expect(headers.get('mcp-name')).toBeNull(); + + // setProtocolVersion (the legacy post-initialize call site, byte-untouched) + // still drives the header for subsequent slot-based sends. + transport.setProtocolVersion('2025-11-25'); + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 2, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} }); + + headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBe('2025-11-25'); + expect(headers.get('mcp-method')).toBeNull(); + }); + + it('a body-derived claim takes precedence over the slot for its own request only', async () => { + transport.setProtocolVersion('2025-11-25'); + + fetchSpy.mockResolvedValueOnce( + okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } }) + ); + await transport.send(probeRequest); + expect(sentHeaders().get('mcp-protocol-version')).toBe('2026-07-28'); + + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 3, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 3, method: 'ping', params: {} }); + expect(sentHeaders().get('mcp-protocol-version')).toBe('2025-11-25'); + }); + + it('batch (array) sends are never body-derived', async () => { + fetchSpy.mockResolvedValueOnce(okJson([{ jsonrpc: '2.0', id: 4, result: {} }])); + await transport.send([probeRequest as never]); + + const headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBeNull(); + expect(headers.get('mcp-method')).toBeNull(); + }); +}); diff --git a/packages/client/test/client/discover.test.ts b/packages/client/test/client/discover.test.ts new file mode 100644 index 0000000000..9e971f1cc5 --- /dev/null +++ b/packages/client/test/client/discover.test.ts @@ -0,0 +1,101 @@ +/** + * Typed `Client.discover()`: issues `server/discover` through the typed + * request funnel on a 2026-era connection; on a 2025-era connection the + * method does not exist (it is absent from the legacy registry), so the + * outbound era gate rejects it locally with a typed error before anything + * reaches the transport. + */ +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +class ScriptedTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sessionId?: string; + sent: JSONRPCMessage[] = []; + + constructor(private readonly script: (message: JSONRPCMessage, transport: ScriptedTransport) => void) {} + + async start(): Promise {} + async close(): Promise { + this.onclose?.(); + } + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + queueMicrotask(() => this.script(message, this)); + } + setProtocolVersion(_version: string): void {} + reply(message: JSONRPCMessage): void { + this.onmessage?.(message); + } +} + +const discoverBody = { + // A real 2026-era server stamps the resultType discriminator on the wire, + // and the 2026 wire shape carries the cacheable-result fields. + resultType: 'complete', + ttlMs: 0, + cacheScope: 'public', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'modern-server', version: '1.0.0' }, + instructions: 'modern instructions' +}; + +/** Answers server/discover (probe and typed request alike) like a modern server. */ +const modernScript = (message: JSONRPCMessage, t: ScriptedTransport) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverBody }); + } +}; + +/** A plain 2025 server: answers initialize, -32601 for everything else. */ +const legacyScript = (message: JSONRPCMessage, t: ScriptedTransport) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'legacy-server', version: '1.0.0' } } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } +}; + +describe('Client.discover()', () => { + test('issues a typed server/discover request on a 2026-era connection', async () => { + const transport = new ScriptedTransport(modernScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(transport); + + const advertisement = await client.discover(); + expect(advertisement.supportedVersions).toEqual([MODERN]); + expect(advertisement.serverInfo).toEqual({ name: 'modern-server', version: '1.0.0' }); + expect(advertisement.instructions).toBe('modern instructions'); + + await client.close(); + }); + + test('is rejected locally with a typed error on a 2025-era connection (the method does not exist on that era)', async () => { + const transport = new ScriptedTransport(legacyScript); + const client = new Client({ name: 'c', version: '0' }); + await client.connect(transport); + + const sentBefore = transport.sent.length; + await expect(client.discover()).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.MethodNotSupportedByProtocolVersion + ); + // Rejected locally: nothing new reached the transport. + expect(transport.sent.length).toBe(sentBefore); + + await client.close(); + }); +}); diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts new file mode 100644 index 0000000000..5318d30b5e --- /dev/null +++ b/packages/client/test/client/probeClassifier.test.ts @@ -0,0 +1,306 @@ +/** + * Row-by-row tests for the merged probe-outcome classifier table. + * + * Each `describe` block names the row of the adjudicated table it covers. The + * HTTP-shaped fixtures mirror the exact bodies deployed servers emit + * (`createJsonErrorResponse`: `{"jsonrpc":"2.0","error":{...},"id":null}`); the + * end-to-end capture of the same shapes from real server transports lives in + * test/integration/test/client/versionNegotiation.test.ts. + */ +import { SdkError, SdkErrorCode, UnsupportedProtocolVersionError } from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import type { ProbeClassifierContext, ProbeOutcome, ProbeVerdict } from '../../src/client/probeClassifier.js'; +import { classifyProbeOutcome } from '../../src/client/probeClassifier.js'; + +const MODERN = '2026-07-28'; +const LEGACY = '2025-11-25'; + +const baseContext: ProbeClassifierContext = { + clientModernVersions: [MODERN], + requestedVersion: MODERN, + fallbackAvailable: true, + environment: 'node', + transportKind: 'http' +}; + +function classify(outcome: ProbeOutcome, context: Partial = {}): ProbeVerdict { + return classifyProbeOutcome(outcome, { ...baseContext, ...context }); +} + +const discoverResult = (supportedVersions: string[]) => ({ + supportedVersions, + capabilities: { tools: {} }, + serverInfo: { name: 'fixture-server', version: '1.0.0' } +}); + +/** The deployed-fleet 400 body for a JSON-RPC error (server streamableHttp `createJsonErrorResponse`). */ +const httpErrorBody = (code: number, message: string, data?: unknown) => + JSON.stringify({ jsonrpc: '2.0', error: data === undefined ? { code, message } : { code, message, data }, id: null }); + +describe('row: DiscoverResult with version overlap → modern, select from supportedVersions', () => { + test('selects the mutual modern version', () => { + const verdict = classify({ kind: 'result', result: discoverResult([MODERN, '2027-01-01']) }); + expect(verdict).toMatchObject({ kind: 'modern', version: MODERN }); + }); + + test('selection follows the client preference order', () => { + const verdict = classify( + { kind: 'result', result: discoverResult(['2027-01-01', MODERN]) }, + { clientModernVersions: ['2027-01-01', MODERN] } + ); + expect(verdict).toMatchObject({ kind: 'modern', version: '2027-01-01' }); + }); + + test('carries the parsed DiscoverResult for connection state', () => { + const verdict = classify({ kind: 'result', result: discoverResult([MODERN]) }); + expect(verdict.kind).toBe('modern'); + if (verdict.kind === 'modern') { + expect(verdict.discover.capabilities).toEqual({ tools: {} }); + expect(verdict.discover.serverInfo.name).toBe('fixture-server'); + } + }); +}); + +describe('row: DiscoverResult with NO overlap → initialize on the same connection, else typed error with synthesized data', () => { + test('fallback possible → legacy (era selection on a dual-era server)', () => { + const verdict = classify({ kind: 'result', result: discoverResult(['2027-12-31']) }); + expect(verdict).toEqual({ kind: 'legacy' }); + }); + + test('fallback impossible → typed UnsupportedProtocolVersionError with synthesized data', () => { + const verdict = classify({ kind: 'result', result: discoverResult(['2027-12-31']) }, { fallbackAvailable: false }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError); + const error = verdict.error as UnsupportedProtocolVersionError; + expect(error.supported).toEqual(['2027-12-31']); + expect(error.requested).toBe(MODERN); + } + }); +}); + +describe('row: -32004 + valid data.supported with a mutual modern version → select-and-continue, MUST NOT fall back', () => { + test('in-band -32004 yields a corrective verdict (never legacy)', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN], requested: '2027-01-01' } + }); + expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN }); + }); + + test('HTTP 400-bodied -32004 yields the same corrective verdict', () => { + const verdict = classify({ + kind: 'http-error', + status: 400, + body: httpErrorBody(-32_004, 'Unsupported protocol version', { supported: [MODERN], requested: MODERN }) + }); + expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN }); + }); + + test('corrective even when the mutual version equals the just-rejected one (T2/A6 — caller runs it exactly once)', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN], requested: MODERN } + }); + expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN }); + if (verdict.kind === 'corrective') { + expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError); + } + }); +}); + +describe('row: -32004 with a disjoint-but-modern list → typed error, never initialize', () => { + test('no mutual modern version but the list is modern', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: ['2027-12-31'], requested: MODERN } + }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError); + expect((verdict.error as UnsupportedProtocolVersionError).supported).toEqual(['2027-12-31']); + } + }); +}); + +describe('row: -32004 with a legacy-only list → initialize; modern-only client → typed error carrying data.supported', () => { + test('legacy-only list with fallback available → legacy', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [LEGACY, '2025-06-18'] } + }); + expect(verdict).toEqual({ kind: 'legacy' }); + }); + + test('legacy-only list, modern-only client → typed error carrying data.supported', () => { + const verdict = classify( + { kind: 'rpc-error', code: -32_004, message: 'Unsupported protocol version', data: { supported: [LEGACY] } }, + { fallbackAvailable: false } + ); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect((verdict.error as UnsupportedProtocolVersionError).supported).toEqual([LEGACY]); + expect((verdict.error as UnsupportedProtocolVersionError).requested).toBe(MODERN); + } + }); + + test('-32004 with malformed data (no valid supported list) → conservative legacy', () => { + expect(classify({ kind: 'rpc-error', code: -32_004, message: 'nope', data: { supported: 'not-a-list' } })).toEqual({ + kind: 'legacy' + }); + expect(classify({ kind: 'rpc-error', code: -32_004, message: 'nope' })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: -32601 → legacy (never modern evidence on the probe, including 200-bodied errors)', () => { + test('in-band -32601 (stdio / 200-bodied HTTP)', () => { + expect(classify({ kind: 'rpc-error', code: -32_601, message: 'Method not found' })).toEqual({ kind: 'legacy' }); + }); + + test('HTTP 404-bodied -32601', () => { + expect(classify({ kind: 'http-error', status: 404, body: httpErrorBody(-32_601, 'Method not found') })).toEqual({ + kind: 'legacy' + }); + }); +}); + +describe('row: 400 + -32000 "Unsupported protocol version" literal (deployed TS-SDK fleet, stateless) → legacy', () => { + test('the byte-real literal body', () => { + // Fixture mirrors server/streamableHttp.ts validateProtocolVersion — the + // Q10-L1 frozen literal, consumed here as a fixture only. + const body = httpErrorBody( + -32_000, + `Bad Request: Unsupported protocol version: ${MODERN} (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)` + ); + expect(classify({ kind: 'http-error', status: 400, body })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: 400 + -32000 free-text (stateful session-required shapes) → legacy', () => { + test('"Server not initialized" (stateful first contact; session is checked before version)', () => { + expect(classify({ kind: 'http-error', status: 400, body: httpErrorBody(-32_000, 'Bad Request: Server not initialized') })).toEqual({ + kind: 'legacy' + }); + }); + + test('"Mcp-Session-Id header is required"', () => { + expect( + classify({ + kind: 'http-error', + status: 400, + body: httpErrorBody(-32_000, 'Bad Request: Mcp-Session-Id header is required') + }) + ).toEqual({ kind: 'legacy' }); + }); + + test('in-band -32000 free-text', () => { + expect(classify({ kind: 'rpc-error', code: -32_000, message: 'Server not initialized' })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: plain-text/unparseable 400, code 0, empty body, 406, any unrecognized shape → legacy (conservative D4)', () => { + test('plain-text 400', () => { + expect(classify({ kind: 'http-error', status: 400, body: 'Bad Request' })).toEqual({ kind: 'legacy' }); + }); + + test('JSON-RPC error with code 0', () => { + expect(classify({ kind: 'rpc-error', code: 0, message: 'weird' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'http-error', status: 400, body: httpErrorBody(0, 'weird') })).toEqual({ kind: 'legacy' }); + }); + + test('empty body', () => { + expect(classify({ kind: 'http-error', status: 400, body: '' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'http-error', status: 400 })).toEqual({ kind: 'legacy' }); + }); + + test('406 Not Acceptable', () => { + expect(classify({ kind: 'http-error', status: 406, body: 'Not Acceptable: Client must accept text/event-stream' })).toEqual({ + kind: 'legacy' + }); + }); + + test('unrecognized 200 result shape (era-ambiguous first-request processing)', () => { + expect(classify({ kind: 'result', result: { ok: true } })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: -32001 / -32003 are NEVER probe-recognized → fall into unrecognized → legacy', () => { + test('-32001 (session-404 overload on deployed servers; ladder cell underived pending conformance #336)', () => { + expect(classify({ kind: 'rpc-error', code: -32_001, message: 'Session not found' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'http-error', status: 404, body: httpErrorBody(-32_001, 'Session not found') })).toEqual({ + kind: 'legacy' + }); + }); + + test('-32003 with data is NOT modern evidence', () => { + expect(classify({ kind: 'rpc-error', code: -32_003, message: 'Capability required', data: { capability: 'sampling' } })).toEqual({ + kind: 'legacy' + }); + }); +}); + +describe('row: network outage → typed connect error (Node)', () => { + test('connection refused is never an era verdict', () => { + const cause = Object.assign(new Error('fetch failed'), { code: 'ECONNREFUSED' }); + const verdict = classify({ kind: 'network-error', error: cause }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(SdkError); + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + } + }); + + test('a Node TypeError (no CORS layer) is still a typed connect error', () => { + const verdict = classify({ kind: 'network-error', error: new TypeError('fetch failed') }, { environment: 'node' }); + expect(verdict.kind).toBe('error'); + }); +}); + +describe('row: timeout — transport-aware verdict', () => { + // The specification's backward-compatibility rule for stdio: "any other + // error, or does not respond within a reasonable timeout: the server is + // legacy. Fall back to the initialize handshake." The versioning + // compatibility matrix draws the same line per transport: stdio probe + // times out → fall back to initialize; on HTTP the legacy signal is a 4xx + // without a recognized modern error body, so silence stays an outage. + test('HTTP: timeout maps to the standard RequestTimeout SdkError (silence on a deployed server is an outage)', () => { + const verdict = classify({ kind: 'timeout', timeoutMs: 60_000 }, { transportKind: 'http' }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(SdkError); + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + } + }); + + test('stdio: timeout is a legacy-server signal → fall back to initialize on the same stream', () => { + expect(classify({ kind: 'timeout', timeoutMs: 5_000 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: browser opaque CORS/preflight TypeError, PROBE PHASE ONLY → legacy fallback (F-7)', () => { + test('browser environment + bare TypeError → legacy', () => { + expect(classify({ kind: 'network-error', error: new TypeError('Failed to fetch') }, { environment: 'browser' })).toEqual({ + kind: 'legacy' + }); + }); + + test('cross-realm TypeError (name-based recognition) → legacy in a browser', () => { + const foreign = new Error('Failed to fetch'); + foreign.name = 'TypeError'; + expect(classify({ kind: 'network-error', error: foreign }, { environment: 'browser' })).toEqual({ kind: 'legacy' }); + }); + + test('browser non-TypeError network failure stays a typed connect error', () => { + const verdict = classify({ kind: 'network-error', error: new Error('socket hang up') }, { environment: 'browser' }); + expect(verdict.kind).toBe('error'); + }); +}); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts new file mode 100644 index 0000000000..a358ca0c30 --- /dev/null +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -0,0 +1,670 @@ +/** + * Connect-time version negotiation: option surface (Q5/Q12), probe mechanics + * (T9), corrective continuation (T2/A6), typed connect errors, fallback + * byte-equivalence at the message level, era scope discipline, and the + * probe-window guard. + * + * Wire-real HTTP first-contact shapes (the -32000 literal and the session- + * required 400) are exercised against real server transports in + * test/integration/test/client/versionNegotiation.test.ts. + */ +import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; +import { + isJSONRPCRequest, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + UnsupportedProtocolVersionError +} from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp.js'; +import type { StdioServerParameters } from '../../src/client/stdio.js'; +import { resolveVersionNegotiation } from '../../src/client/versionNegotiation.js'; + +const MODERN = '2026-07-28'; + +/* ------------------------------------------------------------------------- * + * Q5: option home — dissolved transport/stdio negotiation surfaces stay gone. + * ------------------------------------------------------------------------- */ + +describe('option surface (Q5/Q12)', () => { + test('no Transport.negotiation, no transport/stdio negotiation or probeTimeoutMs options (dissolved surfaces)', () => { + type NotAKeyOf = K extends keyof T ? false : true; + const transportHasNoNegotiation: NotAKeyOf = true; + const httpOptionsHaveNoNegotiation: NotAKeyOf = true; + const stdioHasNoNegotiation: NotAKeyOf = true; + const stdioHasNoProbeTimeout: NotAKeyOf = true; + expect(transportHasNoNegotiation).toBe(true); + expect(httpOptionsHaveNoNegotiation).toBe(true); + expect(stdioHasNoNegotiation).toBe(true); + expect(stdioHasNoProbeTimeout).toBe(true); + }); + + test('absent versionNegotiation resolves to the legacy arm (today’s default; the deferred default ruling is a one-line flip)', () => { + expect(resolveVersionNegotiation(undefined, undefined)).toEqual({ kind: 'legacy' }); + expect(resolveVersionNegotiation({}, undefined)).toEqual({ kind: 'legacy' }); + expect(resolveVersionNegotiation({ mode: 'legacy' }, undefined)).toEqual({ kind: 'legacy' }); + }); + + test('auto resolves default-agnostically: explicit mode never consults the default', () => { + const auto = resolveVersionNegotiation({ mode: 'auto' }, undefined); + expect(auto).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: true }); + }); + + test('a consumer supportedProtocolVersions list drives the offer and the fallback availability', () => { + const modernOnly = resolveVersionNegotiation({ mode: 'auto' }, [MODERN]); + expect(modernOnly).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: false }); + + const mixed = resolveVersionNegotiation({ mode: 'auto' }, ['2027-01-01', MODERN, '2025-11-25']); + expect(mixed).toMatchObject({ kind: 'auto', modernVersions: ['2027-01-01', MODERN], fallbackAvailable: true }); + + const legacyOnly = resolveVersionNegotiation({ mode: 'auto' }, ['2025-11-25']); + expect(legacyOnly).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: true }); + }); + + test('pin requires a modern revision', () => { + expect(resolveVersionNegotiation({ mode: { pin: MODERN } }, undefined)).toMatchObject({ kind: 'pin', version: MODERN }); + expect(() => resolveVersionNegotiation({ mode: { pin: '2025-11-25' } }, undefined)).toThrow(TypeError); + }); +}); + +/* ------------------------------------------------------------------------- * + * Scripted transport for probe mechanics. + * ------------------------------------------------------------------------- */ + +type Script = (message: JSONRPCMessage, transport: ScriptedTransport) => void; + +class ScriptedTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sessionId?: string; + + startCalls = 0; + sent: JSONRPCMessage[] = []; + setProtocolVersionCalls: string[] = []; + + constructor(private readonly script: Script) {} + + async start(): Promise { + this.startCalls++; + if (this.startCalls > 1) { + throw new Error('ScriptedTransport already started! (double-start)'); + } + } + + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + const deliver = () => this.script(message, this); + queueMicrotask(deliver); + } + + async close(): Promise { + this.onclose?.(); + } + + setProtocolVersion(version: string): void { + this.setProtocolVersionCalls.push(version); + } + + reply(message: JSONRPCMessage): void { + this.onmessage?.(message); + } +} + +const discoverResult = (supportedVersions: string[]) => ({ + supportedVersions, + capabilities: {}, + serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } +}); + +/** A scripted dual-era server: answers server/discover with a DiscoverResult and initialize like a 2025 server. */ +function modernServerScript(supportedVersions: string[] = [MODERN]): Script { + return (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult(supportedVersions) }); + } + }; +} + +/** A scripted 2025 server: -32601 for unknown methods, a plain initialize result otherwise. */ +const legacyServerScript: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' } + } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } +}; + +const requests = (sent: JSONRPCMessage[]): JSONRPCRequest[] => sent.filter(isJSONRPCRequest); + +/* ------------------------------------------------------------------------- * + * Probe mechanics (T9) + modern resolution. + * ------------------------------------------------------------------------- */ + +describe('auto mode against a modern server', () => { + test('probe-first with a string id, no initialize, setProtocolVersion exactly once after era resolution', async () => { + const transport = new ScriptedTransport(modernServerScript()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await client.connect(transport); + + const sent = requests(transport.sent); + expect(sent).toHaveLength(1); + const probe = sent[0]!; + // T9: never probe with the first real request; string probe id (no + // collision with Protocol's numeric ids on shared pipes). + expect(probe.method).toBe('server/discover'); + expect(typeof probe.id).toBe('string'); + expect(String(probe.id)).toMatch(/^server-discover-probe-/); + // The probe carries the preferred version in its own _meta envelope. + const meta = (probe.params as { _meta?: Record })._meta; + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + + // No initialize, no notifications/initialized on the modern era. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + expect(transport.sent.some(m => 'method' in m && m.method === 'notifications/initialized')).toBe(false); + + // The transport version slot was never mutated during negotiation; it is + // stamped exactly once, after the era resolved modern. + expect(transport.setProtocolVersionCalls).toEqual([MODERN]); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(client.getServerVersion()?.name).toBe('scripted-modern-server'); + + await client.close(); + }); + + test('the probe window hands the started transport to Protocol.connect without a double start', async () => { + const transport = new ScriptedTransport(modernServerScript()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + // ScriptedTransport.start throws on a second call — reaching here proves + // the handover absorbed Protocol.connect's unconditional start() exactly once. + expect(transport.startCalls).toBe(1); + await client.close(); + }); +}); + +/* ------------------------------------------------------------------------- * + * Fallback: byte-equivalence at the message level + zero version-slot writes. + * ------------------------------------------------------------------------- */ + +describe('auto mode against a legacy server (fallback)', () => { + test('falls back to initialize on the SAME connection; post-probe traffic is identical to a plain legacy connect', async () => { + const autoTransport = new ScriptedTransport(legacyServerScript); + const autoClient = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await autoClient.connect(autoTransport); + + const plainTransport = new ScriptedTransport(legacyServerScript); + const plainClient = new Client({ name: 'c', version: '0' }); + await plainClient.connect(plainTransport); + + // Diff-asserted fallback hygiene: drop the probe, then the auto client's + // entire outbound sequence must be byte-identical to the plain legacy + // client's (same initialize id 0, same body incl. protocolVersion). + const autoSentAfterProbe = autoTransport.sent.slice(1); + expect(JSON.stringify(autoSentAfterProbe)).toBe(JSON.stringify(plainTransport.sent)); + + // Same setProtocolVersion behavior as the plain path (once, with the + // initialize-negotiated version) — nothing was set or cleared around the probe. + expect(autoTransport.setProtocolVersionCalls).toEqual(plainTransport.setProtocolVersionCalls); + + expect(autoClient.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + expect(plainClient.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + await autoClient.close(); + await plainClient.close(); + }); + + test('option-parameterized oracle: a custom supportedProtocolVersions list flows into the fallback initialize body', async () => { + const versions = ['2025-06-18', '2025-03-26']; + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: '2025-06-18', capabilities: {}, serverInfo: { name: 's', version: '1' } } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } + }; + + const autoTransport = new ScriptedTransport(script); + const autoClient = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: versions } + ); + await autoClient.connect(autoTransport); + + const plainTransport = new ScriptedTransport(script); + const plainClient = new Client({ name: 'c', version: '0' }, { supportedProtocolVersions: versions }); + await plainClient.connect(plainTransport); + + expect(JSON.stringify(autoTransport.sent.slice(1))).toBe(JSON.stringify(plainTransport.sent)); + const init = requests(autoTransport.sent)[1]!; + expect((init.params as { protocolVersion?: string }).protocolVersion).toBe('2025-06-18'); + + await autoClient.close(); + await plainClient.close(); + }); + + test('a dual-era supportedProtocolVersions list never leaks a 2026 version into the fallback initialize', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN, '2025-11-25'] } + ); + await client.connect(transport); + + // The fallback initialize offers the first LEGACY version of the list, + // never the 2026-era entry. + const init = requests(transport.sent).find(r => r.method === 'initialize')!; + expect((init.params as { protocolVersion?: string }).protocolVersion).toBe('2025-11-25'); + expect(JSON.stringify(transport.sent.slice(1))).not.toContain(MODERN); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + await client.close(); + }); + + test('a non-conforming server that echoes a 2026 revision from initialize is rejected by the accept check', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: MODERN, capabilities: {}, serverInfo: { name: 's', version: '1' } } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN, '2025-11-25'] } + ); + + await expect(client.connect(transport)).rejects.toThrow(/protocol version is not supported/); + }); + + test('a modern-only client in auto mode gets a typed error instead of a fallback when the server gives no modern evidence', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] } + ); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + // The fallback never ran: no initialize carrying any version was sent. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + // Fallback against REAL servers (in-memory pair, stateful HTTP, stateless + // HTTP — both first-contact wire shapes) is covered in + // test/integration/test/client/versionNegotiation.test.ts. +}); + +/* ------------------------------------------------------------------------- * + * Probe timeout policy: transport-aware. On HTTP-class transports a timeout + * is a typed connect error (silence on a deployed server is an outage); on + * stdio it is a legacy-server signal and falls back to initialize on the same + * stream (the stdio transport's backward-compatibility rule — some legacy + * servers do not respond to unknown pre-initialize requests at all). + * ------------------------------------------------------------------------- */ + +describe('probe timeout policy (transport-aware)', () => { + const silentScript: Script = () => { + /* never replies */ + }; + + test('HTTP-class transport: timeout rejects with the standard typed timeout error and is never converted to a legacy verdict', async () => { + const transport = new ScriptedTransport(silentScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 50 } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + + // Never a legacy verdict: no initialize was attempted, before or after the timeout. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + expect(requests(transport.sent)).toHaveLength(1); + expect(transport.setProtocolVersionCalls).toEqual([]); + }); + + /** A stdio-shaped transport: structurally recognizable by its stderr/pid accessors. */ + class StdioShapedTransport extends ScriptedTransport { + get stderr(): null { + return null; + } + get pid(): number { + return 4242; + } + } + + test('stdio-class transport: a server that never answers the probe is a legacy server — initialize fallback on the same stream', async () => { + // A silent legacy stdio server: ignores the unknown server/discover + // request entirely, but answers initialize like any 2025 server. + const silentLegacyScript: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + legacyServerScript(message, t); + } + // Anything else (the probe) is ignored — no reply at all. + }; + + const transport = new StdioShapedTransport(silentLegacyScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 30 } } }); + + await client.connect(transport); + + // The timeout resolved to the legacy verdict and the initialize fallback + // ran on the SAME transport. + const sent = requests(transport.sent); + expect(sent.filter(r => r.method === 'server/discover')).toHaveLength(1); + expect(sent.some(r => r.method === 'initialize')).toBe(true); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + await client.close(); + }); + + test('stdio-class transport: pin mode still fails loudly on a silent server (no fallback)', async () => { + const transport = new StdioShapedTransport(() => { + /* never replies */ + }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN }, probe: { timeoutMs: 30 } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); +}); + +/* ------------------------------------------------------------------------- * + * -32004 corrective continuation — exactly once; loop guard on second + * rejection. + * ------------------------------------------------------------------------- */ + +describe('-32004 corrective continuation', () => { + test('select-and-continue runs exactly once, even when the mutual version equals the just-rejected one', async () => { + let discoverCalls = 0; + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + discoverCalls++; + if (discoverCalls === 1) { + // Buggy-but-modern server: rejects the version it itself lists. + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN], requested: MODERN } + } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult([MODERN]) }); + } + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + // The corrective continuation is spec-mandated: the second probe still happened. + expect(discoverCalls).toBe(2); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // MUST NOT fall back at any point. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + + await client.close(); + }); + + test('the loop guard arms on the second rejection: typed error, never an infinite continuation', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: [MODERN], requested: MODERN } } + }); + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await expect(client.connect(transport)).rejects.toBeInstanceOf(UnsupportedProtocolVersionError); + expect(requests(transport.sent)).toHaveLength(2); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('-32004 with a disjoint-but-modern list: typed error, never initialize', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: ['2027-12-31'] } } + }); + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await expect(client.connect(transport)).rejects.toBeInstanceOf(UnsupportedProtocolVersionError); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('-32004 with a legacy-only list: definitive legacy signal, initialize on the same connection', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: ['2025-11-25'] } } + }); + } else { + legacyServerScript(message, t); + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + }); + + test('modern-only client + legacy-only -32004 list: typed error carrying data.supported', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: ['2025-11-25'] } } + }); + }; + + const transport = new ScriptedTransport(script); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] } + ); + + const rejection = await client.connect(transport).then( + () => undefined, + error => error as UnsupportedProtocolVersionError + ); + expect(rejection).toBeInstanceOf(UnsupportedProtocolVersionError); + expect(rejection!.supported).toEqual(['2025-11-25']); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); +}); + +/* ------------------------------------------------------------------------- * + * Pin mode: no fallback, loud failure. + * ------------------------------------------------------------------------- */ + +describe('pin mode', () => { + test('modern era at the pinned version when the server offers it', async () => { + const transport = new ScriptedTransport(modernServerScript([MODERN, '2027-01-01'])); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(transport); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); + + test('a legacy server fails loudly — no initialize fallback', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('a modern server without the pinned version fails with typed data — never initialize', async () => { + const transport = new ScriptedTransport(modernServerScript(['2027-12-31'])); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + const rejection = await client.connect(transport).then( + () => undefined, + error => error as UnsupportedProtocolVersionError + ); + expect(rejection).toBeInstanceOf(UnsupportedProtocolVersionError); + expect(rejection!.supported).toEqual(['2027-12-31']); + expect(rejection!.requested).toBe(MODERN); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('a failed negotiation leaves the transport start() untouched (no armed pass-through)', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const originalStart = transport.start; + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + + // The probe window's one-shot start() pass-through must not stay armed + // on a transport the caller still owns after a failed connect. + expect(transport.start).toBe(originalStart); + expect(transport.onmessage).toBeUndefined(); + }); +}); + +/* ------------------------------------------------------------------------- * + * Probe-window guard: pre-init server→client traffic mid-probe is dropped + * with zero bytes. + * ------------------------------------------------------------------------- */ + +describe('probe-window guard', () => { + test('a 2025-legal pre-init server→client request arriving mid-probe is dropped with zero bytes', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + // The server pushes a ping BEFORE answering the probe (legal on a + // 2025 stdio pipe). It must be dropped — no response bytes. + t.reply({ jsonrpc: '2.0', id: 999, method: 'ping' }); + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } else { + legacyServerScript(message, t); + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + // Zero bytes for the dropped request: nothing in the sent log answers id 999. + const repliesTo999 = transport.sent.filter(m => 'id' in m && m.id === 999); + expect(repliesTo999).toEqual([]); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + }); +}); + +/* ------------------------------------------------------------------------- * + * Scope discipline: era is connection state — re-negotiated on every fresh + * connect, never silently demoted on the current connection. + * ------------------------------------------------------------------------- */ + +describe('era scope discipline', () => { + test('every fresh auto connect re-runs negotiation: no verdict survives a reconnect', async () => { + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + // First connect: probe, then fallback. + const first = new ScriptedTransport(legacyServerScript); + await client.connect(first); + expect(requests(first.sent)[0]!.method).toBe('server/discover'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + + // Second (fresh) connect: the negotiated protocol version is connection + // state and is cleared at fresh connect — the probe runs again instead + // of replaying the previous connection's verdict. + const second = new ScriptedTransport(legacyServerScript); + await client.connect(second); + expect(requests(second.sent)[0]!.method).toBe('server/discover'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + }); + + test('an established modern era is never silently demoted: later failures surface, only the NEXT connect re-negotiates', async () => { + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + const transport = new ScriptedTransport(modernServerScript()); + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // A later transport failure does not demote the current connection's era + // and triggers no initialize. + transport.onerror?.(new Error('boom')); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + await client.close(); + + // The next connect re-runs negotiation (the discover exchange doubles as + // the capability fetch). + const next = new ScriptedTransport(modernServerScript()); + await client.connect(next); + expect(requests(next.sent)[0]!.method).toBe('server/discover'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); + + test('no era state exists before the first connect, and none is persisted anywhere', () => { + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + // No cachedEra option surface (deferred-additive). + type NotAKeyOf = K extends keyof T ? false : true; + const noCachedEra: NotAKeyOf[1]>, 'cachedEra'> = true; + expect(noCachedEra).toBe(true); + }); +}); diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index 1f77d1faca..eec7596cc5 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -42,6 +42,15 @@ export enum SdkErrorCode { * `data.method` / `data.era`. */ MethodNotSupportedByProtocolVersion = 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', + /** + * Protocol-era negotiation at connect time failed without producing either a + * usable modern (2026-07-28+) era or a definitive legacy fallback signal — + * e.g. the negotiation mode forbids falling back (`pin`), or the probe hit a + * network failure (a typed connect error, never an era verdict). + * + * Negotiation-phase only: this code is never used once an era is established. + */ + EraNegotiationFailed = 'ERA_NEGOTIATION_FAILED', // Transport errors ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fc022586f5..f5c11a5e0c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export * from './shared/auth.js'; export * from './shared/authUtils.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; +export * from './shared/protocolEras.js'; export * from './shared/stdio.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index f9b9555171..96f9aa67e8 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -379,36 +379,23 @@ type TimeoutInfo = { }; /* - * Package-internal access to Protocol's negotiated-protocol-version state. + * Package-internal write access to Protocol's negotiated-protocol-version state. * - * The negotiated version is a TS-private field on Protocol (it is connection - * state, not public surface — it never appears in the published declaration - * reports). The role classes (Client/Server), tests, and the modern-era - * server entry still need to read and write it at their lifecycle points, so - * Protocol's static initializer hands these module-scoped closures privileged - * access and the two functions below re-export them on the core INTERNAL - * barrel only. This is the F-2-style package-internal hook — deliberately not - * public API. + * The negotiated version is a protected field on Protocol that the role classes + * (Client/Server) assign directly. Tests and the modern-era server entry still + * need to set it from outside the class hierarchy, so Protocol's static + * initializer hands this module-scoped closure privileged access and + * `setNegotiatedProtocolVersion` re-exports it on the core INTERNAL barrel + * only — deliberately not public API. */ -let readNegotiatedProtocolVersion: (instance: Protocol) => string | undefined; let writeNegotiatedProtocolVersion: (instance: Protocol, version: string | undefined) => void; -/** - * Package-internal read channel for the protocol version a {@linkcode Protocol} - * instance has negotiated (`undefined` before negotiation). Exported on the - * core internal barrel only — never public API. - */ -export function negotiatedProtocolVersionOf(instance: Protocol): string | undefined { - return readNegotiatedProtocolVersion(instance); -} - /** * Package-internal write channel for a {@linkcode Protocol} instance's - * negotiated protocol version — the single era set/clear point outside the - * class itself. Called by `Client.connect` (fresh-connect clear + handshake - * completion), `Server._oninitialize`, tests, and the (future) modern-era - * server entry when it marks a factory instance modern at binding time. - * Exported on the core internal barrel only — never public API. + * negotiated protocol version, for callers outside the class hierarchy: + * tests and the (future) modern-era server entry that marks a factory + * instance modern at binding time. Exported on the core internal barrel + * only — never public API. */ export function setNegotiatedProtocolVersion( instance: Protocol, @@ -436,25 +423,14 @@ export abstract class Protocol { private _pendingDebouncedNotifications = new Set(); /** - * The protocol version negotiated for the current connection — the single - * source of truth for the wire era this instance speaks (Q1-SD1: the - * negotiated version cashes out as the negotiated wire ERA). - * - * Ordinary connection state, no side tables: - * - `Client.connect` clears it at the start of a fresh connect (the - * handshake itself runs pre-negotiation) and sets it once the handshake - * completes; the resume path keeps the original negotiation. - * - `Server._oninitialize` sets it when answering the legacy handshake; - * modern-era server instances get it set at instance binding through - * the package-internal hook ({@linkcode setNegotiatedProtocolVersion}). - * - * `undefined` = not negotiated yet: outbound lifecycle messages ride the - * bootstrap method pins and everything else defaults to the legacy era. + * The protocol version negotiated for the current connection (`undefined` + * before negotiation completes), which determines the wire era this + * instance speaks. Set by the SDK's negotiation and initialize paths + * (`Client.connect`, `Server._oninitialize`). */ - private _negotiatedProtocolVersion?: string; + protected _negotiatedProtocolVersion?: string; static { - readNegotiatedProtocolVersion = instance => instance._negotiatedProtocolVersion; writeNegotiatedProtocolVersion = (instance, version) => { instance._negotiatedProtocolVersion = version; }; @@ -732,13 +708,21 @@ export abstract class Protocol { `Era mismatch on inbound request '${request.method}': classified as ${classified} but this instance serves ${codec.era}` ) ); - sendErrorResponse(ProtocolErrorCode.UnsupportedProtocolVersion, `Unsupported protocol version: ${classified}`, { + // `requested` echoes the protocol version the classification + // actually named when it carried one; the wire-era label is + // only the fallback for classifications without an exact + // revision. + const requested = extra.classification.revision ?? classified; + sendErrorResponse(ProtocolErrorCode.UnsupportedProtocolVersion, `Unsupported protocol version: ${requested}`, { // Per spec, `supported` is the full list of protocol // versions the receiver supports — not just the version // this connection is on — so the peer can pick a mutually - // supported version from the error alone. + // supported version from the error alone. (Revisit when + // instances are bound to the modern era at the entry: a + // bound instance's configured list may not name the + // revision it was bound to.) supported: this._supportedProtocolVersions, - requested: classified + requested }); return; } diff --git a/packages/core/src/shared/protocolEras.ts b/packages/core/src/shared/protocolEras.ts new file mode 100644 index 0000000000..a85135fa06 --- /dev/null +++ b/packages/core/src/shared/protocolEras.ts @@ -0,0 +1,41 @@ +/** + * Protocol-era helpers (pure module). The MCP wire protocol splits into two eras: + * legacy (the 2025-11-25 family and earlier; the version is negotiated via the + * `initialize` handshake) and modern (2026-07-28 and later; no `initialize` — + * servers advertise versions via `server/discover` and every request carries a + * `_meta` envelope). + * + * An operation that belongs to one era must only ever consult that era's subset + * of a supported-versions list: `initialize` never accepts or counter-offers a + * modern revision, and the `server/discover` advertisement only ever contains + * modern revisions. + */ + +/** + * The first protocol revision of the modern (2026-07-28) era. Revision identifiers + * are ISO dates, so lexicographic comparison orders them chronologically. + */ +export const FIRST_MODERN_PROTOCOL_VERSION = '2026-07-28'; + +/** + * Modern-era protocol revisions this SDK can negotiate via `server/discover`. + * Deliberately separate from {@linkcode SUPPORTED_PROTOCOL_VERSIONS} (the legacy + * `initialize` list), so adding a revision here can never leak a modern version + * string into a 2025-era handshake. Internal — not part of the public API surface. + */ +export const SUPPORTED_MODERN_PROTOCOL_VERSIONS = [FIRST_MODERN_PROTOCOL_VERSION]; + +/** Whether the given protocol revision belongs to the modern (2026-07-28+) era. */ +export function isModernProtocolVersion(version: string): boolean { + return version >= FIRST_MODERN_PROTOCOL_VERSION; +} + +/** The legacy-era (pre-2026-07-28) subset of a supported-versions list, in the list's own preference order. */ +export function legacyProtocolVersions(versions: readonly string[]): string[] { + return versions.filter(version => !isModernProtocolVersion(version)); +} + +/** The modern-era (2026-07-28+) subset of a supported-versions list, in the list's own preference order. */ +export function modernProtocolVersions(versions: readonly string[]): string[] { + return versions.filter(version => isModernProtocolVersion(version)); +} diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index eec110b960..22e405ff06 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -2015,6 +2015,7 @@ export const RootsListChangedNotificationSchema = NotificationSchema.extend({ export const ClientRequestSchema = z.union([ PingRequestSchema, InitializeRequestSchema, + DiscoverRequestSchema, CompleteRequestSchema, SetLevelRequestSchema, GetPromptRequestSchema, @@ -2060,6 +2061,7 @@ export const ServerNotificationSchema = z.union([ export const ServerResultSchema = z.union([ EmptyResultSchema, InitializeResultSchema, + DiscoverResultSchema, CompleteResultSchema, GetPromptResultSchema, ListPromptsResultSchema, diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 7c8f0d30a2..9af48586c2 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -466,6 +466,7 @@ export type NotificationTypeMap = MethodToTypeMap; } /** diff --git a/packages/core/src/wire/bootstrap.ts b/packages/core/src/wire/bootstrap.ts index 3f54029328..5ae43bb39d 100644 --- a/packages/core/src/wire/bootstrap.ts +++ b/packages/core/src/wire/bootstrap.ts @@ -1,23 +1,28 @@ /** * Static era pins for lifecycle messages on the OUTBOUND path (the - * chicken-and-egg bootstrap): these messages are sent before any negotiated - * version exists, and they self-identify their era by construction — - * `initialize`/`notifications/initialized` ARE the legacy handshake (Q2: - * `initialize` ⇒ legacy), and `server/discover` exists only on the 2026 era. - * No negotiated-state guess ever picks a payload schema for them. + * chicken-and-egg bootstrap): these messages are sent while the instance's + * negotiated protocol version is still unset, and they self-identify their + * era by construction — `initialize`/`notifications/initialized` ARE the + * legacy handshake (`initialize` ⇒ legacy), and `server/discover` exists only + * on the 2026 era. The pins apply only during that pre-negotiation window + * (`Protocol._resolveOutboundCodec` consults them when the negotiated version + * is `undefined`); once a version is negotiated, every send resolves through + * the instance's era. * * Scope notes: - * - OUTBOUND ONLY. Inbound era truth is per-request classification (Q2) with - * session state as fallback — pinning inbound would override the - * classifier (an unclassified `server/discover` request classifies legacy - * and correctly falls to −32601 by registry absence). + * - OUTBOUND ONLY. Inbound era truth is the instance's negotiated protocol + * version (connection state); an edge classification, when present, is + * VALIDATED against that instance era — never used to pick a codec per + * message — so pinning inbound would have nothing to attach to. An + * inbound `server/discover` on a legacy-era instance correctly falls to + * −32601 by registry absence; serving it requires an instance bound to + * the modern era. * - `ping` is deliberately NOT pinned. A bare `{method: 'ping'}` carries no - * era marker — under Q2 it classifies legacy by DEFAULT, not by - * self-identification — and pinning it would let a negotiated-modern - * session emit a 2025-only method onto the modern leg (the exact inverse - * leak registry membership exists to prevent). `ping` era-gates like any - * other method: present on the 2025 era, absent from the 2026 era (the - * modern keepalive story is owned by the negotiation milestones). + * era marker, and pinning it would let a negotiated-modern session emit a + * 2025-only method onto the modern leg (the exact inverse leak registry + * membership exists to prevent). `ping` era-gates like any other method: + * present on the 2025 era, absent from the 2026 era (the modern keepalive + * story is owned by the negotiation milestones). */ import type { WireCodec } from './codec.js'; import { codecForVersion, MODERN_WIRE_REVISION } from './codec.js'; diff --git a/packages/core/src/wire/rev2025-11-25/registry.ts b/packages/core/src/wire/rev2025-11-25/registry.ts index e865fb58ea..e81d1e90c8 100644 --- a/packages/core/src/wire/rev2025-11-25/registry.ts +++ b/packages/core/src/wire/rev2025-11-25/registry.ts @@ -9,8 +9,9 @@ * BEHAVIOR-FROZEN behind the Q10-L2 byte-identity suite: the request and * notification maps carry the full deliberate 2025-11-25 wire vocabulary, * including the task family (the #2248 wire-interop restore). The RESULT map - * is the runtime/typed ALIGNED map (PR #2293 review): keyed by - * `RequestMethod` so it cannot drift from the typed `ResultTypeMap` — no + * is the runtime/typed ALIGNED map (PR #2293 review): keyed by this era's + * subset of the typed `RequestMethod` set so it cannot drift from the typed + * `ResultTypeMap` — no * task-result union members and no `tasks/*` entries; a task-capable 2025 * peer's `CreateTaskResult` answer fails the plain per-method schema as a * typed invalid-result error, and callers needing task interop pass an @@ -87,15 +88,27 @@ export type Rev2025RequestMethod = WireRequest['method']; /** Every notification method in the 2025-era wire vocabulary. */ export type Rev2025NotificationMethod = WireNotification['method']; +/** + * The typed-method surface this era serves: the typed `RequestMethod` set + * minus methods whose wire vocabulary does not exist on this era (e.g. + * `server/discover`, which the typed maps carry but only the 2026-era + * registry serves). Deriving the subset from the era's own wire role unions + * keeps the both-direction drift guard: a typed 2025-era method without a map + * entry, or a map entry the era's wire vocabulary does not know, is a compile + * error. + */ +type Rev2025TypedRequestMethod = Extract; + /* Runtime schema lookup — result schemas by method */ -// Keyed by `RequestMethod` and valued by `z.ZodType` so the -// runtime map and the typed `ResultTypeMap` cannot drift: a missing entry, an -// extra key, or an entry that does not parse to the typed map's result type -// is a compile error. No entry may be looser than the typed map (no -// task-result union members) and no key may fall outside it (no `tasks/*` -// entries — the task methods are 2025-11-25 wire vocabulary with no SDK -// runtime; callers needing task interop pass an explicit schema). -const resultSchemas: { readonly [M in RequestMethod]: z.ZodType } = { +// Keyed by the era's typed-method subset and valued by +// `z.ZodType` so the runtime map and the typed +// `ResultTypeMap` cannot drift: a missing entry, an extra key, or an entry +// that does not parse to the typed map's result type is a compile error. No +// entry may be looser than the typed map (no task-result union members) and +// no key may fall outside it (no `tasks/*` entries — the task methods are +// 2025-11-25 wire vocabulary with no SDK runtime; callers needing task +// interop pass an explicit schema). +const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType } = { ping: EmptyResultSchema, initialize: InitializeResultSchema, 'completion/complete': CompleteResultSchema, @@ -167,19 +180,19 @@ export function hasNotificationMethod2025(method: string): method is Rev2025Noti return Object.prototype.hasOwnProperty.call(notificationSchemas, method); } -/** Result-map membership: exactly the typed `RequestMethod` set (no task entries). */ -function hasResultMethod(method: string): method is RequestMethod { +/** Result-map membership: exactly the era's typed-method subset (no task entries, no 2026-only methods). */ +function hasResultMethod(method: string): method is Rev2025TypedRequestMethod { return Object.prototype.hasOwnProperty.call(resultSchemas, method); } /** * Gets the Zod schema for validating results of a given request method. - * Returns `undefined` for non-spec methods. + * Returns `undefined` for non-spec methods and 2026-only methods. * The typed overload is backed by the map's own typing (`z.ZodType` - * per entry), so callers with a statically known method can use the parsed - * value without a type assertion. + * per entry), so callers with a statically known 2025-era method can use the + * parsed value without a type assertion. */ -export function getResultSchema(method: M): z.ZodType; +export function getResultSchema(method: M): z.ZodType; export function getResultSchema(method: string): z.ZodType | undefined; export function getResultSchema(method: string): z.ZodType | undefined { return hasResultMethod(method) ? resultSchemas[method] : undefined; @@ -187,11 +200,11 @@ export function getResultSchema(method: string): z.ZodType | undefined { /** * Gets the Zod schema for a given request method. - * Returns `undefined` for non-spec methods. + * Returns `undefined` for non-spec methods and 2026-only methods. * The typed overload returns a ZodType that parses to `RequestTypeMap[M]`, * allowing callers to use `schema.parse()` without additional type assertions. */ -export function getRequestSchema(method: M): z.ZodType; +export function getRequestSchema(method: M): z.ZodType; export function getRequestSchema(method: string): z.ZodType | undefined; export function getRequestSchema(method: string): z.ZodType | undefined { return hasRequestMethod2025(method) ? requestSchemas[method] : undefined; diff --git a/packages/core/test/shared/protocolEras.test.ts b/packages/core/test/shared/protocolEras.test.ts new file mode 100644 index 0000000000..c01c97fdad --- /dev/null +++ b/packages/core/test/shared/protocolEras.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest'; + +import { + FIRST_MODERN_PROTOCOL_VERSION, + isModernProtocolVersion, + legacyProtocolVersions, + modernProtocolVersions, + SUPPORTED_MODERN_PROTOCOL_VERSIONS +} from '../../src/shared/protocolEras.js'; +import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '../../src/types/constants.js'; + +describe('protocol era helpers', () => { + test('every released (legacy-list) version is classified legacy', () => { + for (const version of SUPPORTED_PROTOCOL_VERSIONS) { + expect(isModernProtocolVersion(version)).toBe(false); + } + expect(legacyProtocolVersions(SUPPORTED_PROTOCOL_VERSIONS)).toEqual(SUPPORTED_PROTOCOL_VERSIONS); + expect(modernProtocolVersions(SUPPORTED_PROTOCOL_VERSIONS)).toEqual([]); + }); + + test('the 2026-07-28 revision and later are classified modern', () => { + expect(isModernProtocolVersion('2026-07-28')).toBe(true); + expect(isModernProtocolVersion('2027-01-01')).toBe(true); + expect(FIRST_MODERN_PROTOCOL_VERSION).toBe('2026-07-28'); + }); + + test('subsetting preserves the list preference order', () => { + const mixed = ['2026-07-28', LATEST_PROTOCOL_VERSION, '2025-06-18']; + expect(modernProtocolVersions(mixed)).toEqual(['2026-07-28']); + expect(legacyProtocolVersions(mixed)).toEqual([LATEST_PROTOCOL_VERSION, '2025-06-18']); + }); + + test('era-disjoint constants: the modern list never feeds the legacy initialize list', () => { + // Ordering guard (counter-offer leak, server.ts counter-offer site): the + // legacy SUPPORTED_PROTOCOL_VERSIONS constant must not contain modern + // revisions; modern negotiation reads SUPPORTED_MODERN_PROTOCOL_VERSIONS, + // which must contain only modern revisions. + expect(SUPPORTED_PROTOCOL_VERSIONS.some(isModernProtocolVersion)).toBe(false); + expect(SUPPORTED_MODERN_PROTOCOL_VERSIONS.every(isModernProtocolVersion)).toBe(true); + }); +}); diff --git a/packages/core/test/types/discoverWiring.test.ts b/packages/core/test/types/discoverWiring.test.ts new file mode 100644 index 0000000000..b17b96101c --- /dev/null +++ b/packages/core/test/types/discoverWiring.test.ts @@ -0,0 +1,54 @@ +/** + * LC-02: `server/discover` wired into the typed request funnel — the wire + * shapes landed earlier but were deliberately union-excluded; this pins the + * widening into ClientRequestSchema / ServerResultSchema / the typed method + * maps. Per-era AVAILABILITY stays with the wire registries (one source of + * truth): the 2026-era registry serves the method, the 2025-era registry does + * not — there is no neutral runtime schema map to keep in sync. + */ +import { describe, expect, expectTypeOf, test } from 'vitest'; + +import { ClientRequestSchema, DiscoverResultSchema, ServerResultSchema } from '../../src/types/index.js'; +import type { DiscoverResult, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../src/types/index.js'; +import { getRequestSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry.js'; +import { getRequestSchema2026, getResultSchema2026 } from '../../src/wire/rev2026-07-28/registry.js'; + +describe('server/discover typed-funnel wiring (LC-02)', () => { + test('ClientRequestSchema accepts a server/discover request', () => { + const parsed = ClientRequestSchema.safeParse({ method: 'server/discover' }); + expect(parsed.success).toBe(true); + }); + + test('ServerResultSchema accepts a discover result', () => { + const parsed = ServerResultSchema.safeParse({ + supportedVersions: ['2026-07-28'], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + }); + expect(parsed.success).toBe(true); + }); + + test('the typed method maps carry server/discover', () => { + expectTypeOf<'server/discover'>().toExtend(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toMatchObjectType<{ method: 'server/discover' }>(); + }); + + test('per-era availability lives in the wire registries: 2026 serves it, 2025 does not', () => { + expect(getRequestSchema2026('server/discover')).toBeDefined(); + expect(getResultSchema2026('server/discover')).toBeDefined(); + expect(getRequestSchema('server/discover')).toBeUndefined(); + expect(getResultSchema('server/discover')).toBeUndefined(); + }); + + test('a discover result round-trips the schema with its advertisement intact', () => { + const result = DiscoverResultSchema.parse({ + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + serverInfo: { name: 'modern-server', version: '2.0.0' }, + instructions: 'use the tools' + }); + expect(result.supportedVersions).toEqual(['2026-07-28']); + expect(result.instructions).toBe('use the tools'); + }); +}); diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts index bb5fb64325..46003004e4 100644 --- a/packages/core/test/types/errorSurfacePins.test.ts +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -76,6 +76,7 @@ describe('SdkErrorCode', () => { InvalidResult: 'INVALID_RESULT', UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', MethodNotSupportedByProtocolVersion: 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', + EraNegotiationFailed: 'ERA_NEGOTIATION_FAILED', ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', ClientHttpAuthentication: 'CLIENT_HTTP_AUTHENTICATION', ClientHttpForbidden: 'CLIENT_HTTP_FORBIDDEN', diff --git a/packages/core/test/wire/eraGates.test.ts b/packages/core/test/wire/eraGates.test.ts index 97d2ba3313..8776ee69c0 100644 --- a/packages/core/test/wire/eraGates.test.ts +++ b/packages/core/test/wire/eraGates.test.ts @@ -418,6 +418,25 @@ describe('the edge→instance handoff — classification is validated, never an expect(h.errors.some(e => e.message.includes('Era mismatch'))).toBe(true); }); + test('the rejection’s data.requested names the exact revision the classification carried, not just the era label', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} } as JSONRPCMessage, { + era: 'legacy', + revision: '2025-06-18' + }); + await h.flush(); + + const error = errorOf(h.sent[0]) as { code: number; data?: { requested?: string } } | undefined; + expect(error?.code).toBe(-32004); + expect(error?.data?.requested).toBe('2025-06-18'); + }); + test('a modern-classified notification on a legacy-era instance is dropped, with onerror', async () => { let delivered = 0; const h = await harness({ @@ -498,6 +517,24 @@ describe('outbound era gates — typed local error before the transport', () => expect(h.sent).toHaveLength(0); }); + test('_requestWithSchema applies the same outbound era gate: an explicit schema never smuggles a deleted method', async () => { + const h = await harness({ era: '2026-07-28' }); + const requestWithSchema = ( + h.receiver as unknown as { + _requestWithSchema: (request: { method: string }, schema: unknown) => Promise; + } + )._requestWithSchema.bind(h.receiver); + + expect(() => requestWithSchema({ method: 'ping' }, z.object({}))).toThrow(SdkError); + try { + requestWithSchema({ method: 'ping' }, z.object({})); + } catch (error) { + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).data).toMatchObject({ method: 'ping', era: '2026-07-28' }); + } + expect(h.sent).toHaveLength(0); + }); + test('pre-negotiation bootstrap pins still route initialize to the 2025 era', async () => { // An instance with NO negotiated version may always send the legacy // handshake; setting a modern version afterwards closes it (the pin diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 68a0c224f0..579db2f2fe 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -152,6 +152,17 @@ export class NodeStreamableHTTPServerTransport implements Transport { return this._webStandardTransport.send(message, options); } + /** + * Forwards the supported protocol versions to the wrapped Web Standard + * transport for `MCP-Protocol-Version` header validation. Called by the + * protocol layer during connect; without this delegation a server's + * `supportedProtocolVersions` option never reached the Node adapter's + * header validation. + */ + setSupportedProtocolVersions(versions: string[]): void { + this._webStandardTransport.setSupportedProtocolVersions(versions); + } + /** * Handles an incoming HTTP request, whether `GET` or `POST`. * diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index a82432d968..e783940536 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -6,6 +6,7 @@ import type { CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, + DiscoverResult, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, @@ -38,16 +39,16 @@ import { CreateMessageResultSchema, CreateMessageResultWithToolsSchema, LATEST_PROTOCOL_VERSION, + legacyProtocolVersions, LoggingLevelSchema, mergeCapabilities, - negotiatedProtocolVersionOf, + modernProtocolVersions, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode, - setNegotiatedProtocolVersion + SdkErrorCode } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -114,6 +115,12 @@ export class Server extends Protocol { this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); + // server/discover is installed only when the supported-versions list + // carries a modern revision: a legacy-only server keeps answering -32601. + if (modernProtocolVersions(this._supportedProtocolVersions).length > 0) { + this.setRequestHandler('server/discover', () => this._ondiscover()); + } + if (this._capabilities.logging) { this._registerLoggingHandler(); } @@ -207,7 +214,7 @@ export class Server extends Protocol { // Era-exact validation: the request and result schemas come from // the instance era, resolved at dispatch time (the era gate // guarantees tools/call exists on the serving era). - const codec = codecForVersion(negotiatedProtocolVersionOf(this)); + const codec = codecForVersion(this._negotiatedProtocolVersion); const callToolRequestSchema = codec.requestSchema('tools/call'); // The era registry entry IS the plain CallToolResult schema (the // result map is aligned to the typed map — no widened unions), @@ -386,14 +393,18 @@ export class Server extends Protocol { this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion) + // A 2026-07-28-or-later revision is NEVER negotiated via the legacy + // `initialize` handshake — only ever selected through `server/discover` — + // so the accept check and counter-offer consult only the legacy subset. + const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); + const protocolVersion = legacyVersions.includes(requestedVersion) ? requestedVersion - : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); + : (legacyVersions[0] ?? LATEST_PROTOCOL_VERSION); // The negotiated version is the instance's connection state — it IS // the wire-era selection for everything this instance sends and // receives from here on (legacy handshake ⇒ a legacy-era version). - setNegotiatedProtocolVersion(this, protocolVersion); + this._negotiatedProtocolVersion = protocolVersion; this.transport?.setProtocolVersion?.(protocolVersion); return { @@ -404,6 +415,21 @@ export class Server extends Protocol { }; } + /** + * Answers `server/discover` (protocol revision 2026-07-28). `supportedVersions` + * lists only modern revisions (2025-era versions are negotiated via `initialize`); + * the advertised capabilities exclude the listChanged/subscribe-class capabilities + * (see {@linkcode discoverAdvertisedCapabilities}). + */ + private _ondiscover(): DiscoverResult { + return { + supportedVersions: modernProtocolVersions(this._supportedProtocolVersions), + capabilities: discoverAdvertisedCapabilities(this.getCapabilities()), + serverInfo: this._serverInfo, + ...(this._instructions && { instructions: this._instructions }) + }; + } + /** * After initialization has completed, this will be populated with the client's reported capabilities. */ @@ -424,7 +450,7 @@ export class Server extends Protocol { * `undefined` before initialization. */ getNegotiatedProtocolVersion(): string | undefined { - return negotiatedProtocolVersionOf(this); + return this._negotiatedProtocolVersion; } /** @@ -671,3 +697,28 @@ export class Server extends Protocol { return this.notification({ method: 'notifications/prompts/list_changed' }); } } + +/** + * The capability set a server advertises on `server/discover`: until the + * `subscriptions/listen` flow ships, the advertisement excludes the + * listChanged/subscribe-class capabilities, which a modern-era connection + * cannot be served yet. Pure — never mutates the input; the legacy + * `initialize` advertisement is untouched. + */ +export function discoverAdvertisedCapabilities(capabilities: ServerCapabilities): ServerCapabilities { + const advertised: ServerCapabilities = { ...capabilities }; + if (capabilities.tools) { + advertised.tools = { ...capabilities.tools }; + delete advertised.tools.listChanged; + } + if (capabilities.prompts) { + advertised.prompts = { ...capabilities.prompts }; + delete advertised.prompts.listChanged; + } + if (capabilities.resources) { + advertised.resources = { ...capabilities.resources }; + delete advertised.resources.listChanged; + delete advertised.resources.subscribe; + } + return advertised; +} diff --git a/packages/server/test/server/classificationCarrierPin.test.ts b/packages/server/test/server/classificationCarrierPin.test.ts new file mode 100644 index 0000000000..00e135dd0f --- /dev/null +++ b/packages/server/test/server/classificationCarrierPin.test.ts @@ -0,0 +1,131 @@ +/** + * B-2 rule pin: hand-wired legacy-transport traffic is NEVER + * Protocol-classified. + * + * Discriminator: messages delivered by the hand-wired streamable HTTP server + * transport carry `extra.request` (the HTTP side channel) but `extra.classification` + * stays UNSET — the carrier exists for edge classifiers (the 2026 entry), and + * the Protocol layer must not classify on their behalf. A modern-stamped body + * (full 2026 `_meta` envelope) pushed through a legacy transport gets today's + * exact legacy semantics, byte-identical to the same body without the envelope + * claim where the envelope does not participate (the reserved keys are lifted + * from `_meta`, exactly as for any legacy request carrying them). + */ +import type { MessageExtraInfo } from '@modelcontextprotocol/core'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Server } from '../../src/server/server.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; + +const MODERN = '2026-07-28'; + +async function setupHandWired() { + const server = new Server({ name: 'pin-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async () => ({ content: [{ type: 'text', text: 'pinned' }] })); + server.setRequestHandler('tools/list', async () => ({ tools: [] })); + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + await server.connect(transport); + + // Hand-wired observation point: chain onto the transport callback the same + // way a consumer wrapping the transport would (wrappable-after-connect). + const seen: Array<{ method?: string; extra?: MessageExtraInfo }> = []; + const previous = transport.onmessage; + transport.onmessage = (message, extra) => { + seen.push({ method: (message as { method?: string }).method, extra }); + previous?.(message, extra); + }; + + const post = async (body: unknown): Promise<{ status: number; text: string }> => { + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body) + }) + ); + return { status: response.status, text: await response.text() }; + }; + + return { server, transport, seen, post }; +} + +const toolsCall = (meta?: Record) => ({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { + name: 'anything', + arguments: {}, + ...(meta !== undefined && { _meta: meta }) + } +}); + +const modernEnvelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +describe('B-2: hand-wired legacy-transport traffic is never Protocol-classified', () => { + it('extra.request is set and extra.classification stays unset for every delivered message', async () => { + const { server, seen, post } = await setupHandWired(); + + await post(toolsCall()); + await post(toolsCall(modernEnvelope)); + + expect(seen.length).toBeGreaterThanOrEqual(2); + for (const { extra } of seen) { + expect(extra?.request).toBeInstanceOf(Request); + expect(extra?.classification).toBeUndefined(); + } + + await server.close(); + }); + + it('a modern-stamped body through the legacy transport gets today’s exact legacy semantics, byte-identical', async () => { + const plainSetup = await setupHandWired(); + const plainResponse = await plainSetup.post(toolsCall()); + await plainSetup.server.close(); + + const stampedSetup = await setupHandWired(); + const stampedResponse = await stampedSetup.post(toolsCall(modernEnvelope)); + await stampedSetup.server.close(); + + // Byte-identical response: the envelope claim does not flip an era, does + // not change the result shape, does not get echoed back. + expect(stampedResponse.status).toBe(plainResponse.status); + expect(stampedResponse.text).toBe(plainResponse.text); + expect(stampedResponse.text).toContain('pinned'); + expect(stampedResponse.text).not.toContain(MODERN); + }); + + it('a modern-stamped initialize through the legacy transport negotiates exactly like today (no modern era)', async () => { + const { server, post } = await setupHandWired(); + + const { status, text } = await post({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: MODERN, + capabilities: {}, + clientInfo: { name: 'modern-client', version: '1.0.0' }, + _meta: modernEnvelope + } + }); + + expect(status).toBe(200); + const parsed = JSON.parse(text) as { result: { protocolVersion: string } }; + // Today's exact legacy semantics: the unknown requested version is + // countered with the latest released version; the body stamp does not + // make the legacy transport modern. + expect(parsed.result.protocolVersion).toBe('2025-11-25'); + + await server.close(); + }); +}); diff --git a/packages/server/test/server/discover.test.ts b/packages/server/test/server/discover.test.ts new file mode 100644 index 0000000000..c2b96da595 --- /dev/null +++ b/packages/server/test/server/discover.test.ts @@ -0,0 +1,211 @@ +/** + * `server/discover` machinery + era-aware supported-version list semantics: + * + * - the handler is installed ONLY when the server's supported-versions list + * carries a modern (2026-07-28+) revision; default servers keep answering + * -32601 byte-identically to the deployed fleet + * - the advertisement is modern-only (DV-30) and excludes the + * listChanged/subscribe-class capabilities (A11 rider — until the + * subscriptions/listen milestone lands) + * - counter-offer ordering: with era-aware list semantics in place, a legacy + * initialize can never meet a modern version string at the counter-offer + * site, even when the supported list carries one — the guard that must hold + * BEFORE any LATEST/SUPPORTED constant bump. + * + * Era is instance state: an inbound `server/discover` is served only by a + * modern-era instance (the method is physically absent from the legacy + * registry). Production marking of modern instances is owned by the + * server-entry milestone; these tests mark instances through the + * package-internal hook the entry will use, and the modern-era request shape + * carries the required per-request `_meta` envelope. + */ +import type { DiscoverResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + DiscoverResultSchema, + InitializeResultSchema, + InMemoryTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { discoverAdvertisedCapabilities, Server } from '../../src/server/server.js'; + +const MODERN = '2026-07-28'; +/** A supported list spanning both eras — what the constant becomes after a future bump. */ +const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS]; + +async function sendRaw(server: Server, request: JSONRPCRequest, options?: { markModern?: boolean }): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + if (options?.markModern) { + // Stand-in for the modern-era server entry (instance binding): mark the + // instance as serving the modern era so the era gate admits the method. + setNegotiatedProtocolVersion(server, MODERN); + } + const responsePromise = new Promise(resolve => { + clientTransport.onmessage = msg => resolve(msg); + }); + await clientTransport.start(); + await clientTransport.send(request); + return responsePromise; +} + +/** A wire-true modern discover request: the 2026 era requires the per-request `_meta` envelope. */ +const discoverRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } +}; + +const initializeRequest = (requestedVersion: string): JSONRPCRequest => ({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: requestedVersion, capabilities: {}, clientInfo: { name: 'test-client', version: '1.0.0' } } +}); + +describe('server/discover handler gating', () => { + it('a default (legacy-only) server answers server/discover with -32601, byte-identical to the deployed fleet', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const response = await sendRaw(server, discoverRequest); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + } + await server.close(); + }); + + it('a server with a modern revision in its supported list serves discover on a modern-era instance', async () => { + const server = new Server( + { name: 'modern-server', version: '2.0.0' }, + { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, instructions: 'hello' } + ); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + const result = DiscoverResultSchema.parse(response.result); + expect(result.supportedVersions).toEqual([MODERN]); + expect(result.serverInfo).toEqual({ name: 'modern-server', version: '2.0.0' }); + expect(result.instructions).toBe('hello'); + } + await server.close(); + }); + + it('a modern-era instance whose supported list carries no modern revision still answers -32601 (handler not installed)', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + } + await server.close(); + }); +}); + +describe('discover advertisement constraints', () => { + it('advertises modern-only versions (DV-30): no 2025-era string ever appears in supportedVersions', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = DiscoverResultSchema.parse(response.result); + expect(result.supportedVersions).toEqual([MODERN]); + for (const version of result.supportedVersions) { + expect(version >= MODERN).toBe(true); + } + await server.close(); + }); + + it('excludes listChanged/subscribe-class capabilities (A11 rider, until subscriptions/listen lands)', async () => { + const server = new Server( + { name: 'test', version: '1.0.0' }, + { + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + logging: {}, + completions: {} + }, + supportedProtocolVersions: DUAL_ERA_VERSIONS + } + ); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = DiscoverResultSchema.parse(response.result) as DiscoverResult; + + expect(result.capabilities.tools).toEqual({}); + expect(result.capabilities.prompts).toEqual({}); + expect(result.capabilities.resources).toEqual({}); + expect(result.capabilities.logging).toEqual({}); + expect(result.capabilities.completions).toEqual({}); + expect(JSON.stringify(result.capabilities)).not.toContain('listChanged'); + expect(JSON.stringify(result.capabilities)).not.toContain('subscribe'); + + await server.close(); + }); + + it('discoverAdvertisedCapabilities is pure and leaves the initialize advertisement untouched', async () => { + const capabilities = { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }; + const stripped = discoverAdvertisedCapabilities(capabilities); + expect(stripped).toEqual({ tools: {}, resources: {} }); + // No mutation of the input. + expect(capabilities).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }); + + // The legacy initialize advertisement still carries the full capability set. + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const response = await sendRaw(server, initializeRequest(LATEST_PROTOCOL_VERSION)); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + expect(result.capabilities.tools).toEqual({ listChanged: true }); + expect(result.capabilities.resources).toEqual({ subscribe: true, listChanged: true }); + await server.close(); + }); +}); + +describe('era-aware counter-offer ordering (the guard that precedes any constant bump)', () => { + it('an unknown requested version is countered with the latest LEGACY version even when the list carries a modern one', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const response = await sendRaw(server, initializeRequest('1999-01-01')); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + // supportedProtocolVersions[0] is the modern revision here — the + // counter-offer must NOT be it: a fallback initialize never meets a + // leaked 2026 string at this site. + expect(result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(result.protocolVersion).not.toBe(MODERN); + await server.close(); + }); + + it('an initialize REQUESTING the modern revision is also answered with the latest legacy version (initialize never negotiates a modern era)', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const response = await sendRaw(server, initializeRequest(MODERN)); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + expect(result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + await server.close(); + }); + + it('default-list behavior is byte-identical: the legacy subset IS the whole list today', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const response = await sendRaw(server, initializeRequest('1999-01-01')); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + expect(result.protocolVersion).toBe(SUPPORTED_PROTOCOL_VERSIONS[0]); + await server.close(); + }); +}); diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 4ca198535b..e47a0f065b 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -131,16 +131,15 @@ describe('Server', () => { }); it('counter-offers only released versions when a draft revision is requested', async () => { - // ORDERING PIN — counter-offer leak guard. The initialize - // counter-offer is `supportedProtocolVersions[0]`: whatever sits at - // the head of that list is offered to EVERY legacy-era client whose - // requested version is unknown. Era-aware supported-version list - // semantics must therefore land BEFORE any LATEST/SUPPORTED - // constant bump that adds a 2026-era revision — bumping first - // would leak the modern revision into 2025-era initialize - // handshakes via this exact site. If this pin goes red because the - // constants moved, do NOT update it until the counter-offer is - // era-aware. + // ORDERING PIN — counter-offer leak guard. The initialize accept + // check and counter-offer are now ERA-AWARE: they consult only the + // legacy (pre-2026-07-28) subset of `supportedProtocolVersions`, + // because a 2026-07-28-or-later revision is never negotiated via + // the legacy initialize handshake (it is only selected through + // server/discover). This pin holds even after a future + // LATEST/SUPPORTED constant bump adds a modern revision: the + // counter-offer can never name it. The dual-era list arms live in + // discover.test.ts ("era-aware counter-offer ordering"). const DRAFT_REVISION = '2026-07-28'; expect(SUPPORTED_PROTOCOL_VERSIONS).not.toContain(DRAFT_REVISION); const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 750b8e537b..7f5d68077e 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2193,7 +2193,7 @@ export const REQUIREMENTS: Record = { behavior: 'An app created by createMcpExpressApp() with the default localhost host applies DNS-rebinding protection: a request whose Host header is not an allowed local host is rejected with 403 before reaching the MCP transport.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. The allowed-host control asserts initialize semantics per spec version: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' }, 'custom-methods:server-handler:roundtrip': { source: 'sdk', @@ -2384,14 +2384,15 @@ export const REQUIREMENTS: Record = { 'transport:standalone:raw-relay': { source: 'sdk', behavior: - 'Client and server transports can be driven directly (start/send/onmessage/onclose/onerror) without wrapping them in a Client or Server, supporting message-relay proxies.' + 'Client and server transports can be driven directly (start/send/onmessage/onclose/onerror) without wrapping them in a Client or Server, supporting message-relay proxies.', + note: 'Against real SDK servers the relayed initialize negotiates per initialize semantics: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' }, 'transport:custom:client-connect': { source: 'sdk', behavior: 'Client.connect accepts any consumer-implemented object satisfying the Transport interface and completes the handshake over it.', transports: ['inMemory'], - note: 'The test supplies its own custom Transport implementation, so the matrix transport arg is ignored; it runs as a single inMemory-labelled cell to avoid duplicate runs.' + note: 'The test supplies its own custom Transport implementation, so the matrix transport arg is ignored; it runs as a single inMemory-labelled cell to avoid duplicate runs. On 2026-era cells the handshake is the server/discover negotiation (opted into via versionNegotiation); on 2025-era cells it is the plain initialize exchange.' }, 'protocol:transport-callbacks:wrappable-after-connect': { source: 'sdk', diff --git a/test/e2e/scenarios/hosting-express.test.ts b/test/e2e/scenarios/hosting-express.test.ts index fbc9851c5e..f4a3141a78 100644 --- a/test/e2e/scenarios/hosting-express.test.ts +++ b/test/e2e/scenarios/hosting-express.test.ts @@ -24,7 +24,7 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/cli import { createMcpExpressApp, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { OAuthMetadata } from '@modelcontextprotocol/server'; -import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import { LATEST_PROTOCOL_VERSION, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; import type { Express, RequestHandler } from 'express'; import express from 'express'; import { expect } from 'vitest'; @@ -475,13 +475,16 @@ verifies('hosting:express:adapter-host-header-validation', async ({ protocolVers expect(mcpRouteHits).toBe(0); // Control: the identical request with the real localhost Host reaches the transport and initializes normally. + // The negotiated version follows initialize semantics: a 2026-era request is answered with the latest legacy + // version (2026-era revisions are never negotiated via initialize); legacy requests are echoed back. + const negotiatedVersion = protocolVersion >= '2026-07-28' ? LATEST_PROTOCOL_VERSION : protocolVersion; const allowed = await postWithHost(new URL('/mcp', baseUrl), `127.0.0.1:${baseUrl.port}`, initializeBody); expect(allowed.status).toBe(200); const allowedJson: unknown = JSON.parse(allowed.body); expect(allowedJson).toMatchObject({ jsonrpc: '2.0', id: 1, - result: { protocolVersion, serverInfo: { name: 'rebind-protected-server', version: '1.0.0' } } + result: { protocolVersion: negotiatedVersion, serverInfo: { name: 'rebind-protected-server', version: '1.0.0' } } }); expect(mcpRouteHits).toBe(1); } finally { diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 40b5a20af3..4cb0406763 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -1535,16 +1535,40 @@ class LoopbackTransport implements Transport { this.events.push('method' in message ? `send:${message.method}` : 'send:response'); if (!isRequest(message)) return; this.clientRequests.push(message); - if (message.method === 'initialize') { - this.respond(message.id, { - protocolVersion: this.serverProtocolVersion, - capabilities: { tools: {} }, - serverInfo: { name: 'loopback-server', version: '3.1.4' } - }); - } else if (message.method === 'tools/list') { - this.respond(message.id, { - tools: [{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }] - }); + const modern = this.serverProtocolVersion >= '2026-07-28'; + switch (message.method) { + case 'initialize': { + this.respond(message.id, { + protocolVersion: this.serverProtocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'loopback-server', version: '3.1.4' } + }); + break; + } + case 'server/discover': { + // The 2026-era handshake: advertise the canned identity instead of + // answering an initialize exchange. + this.respond(message.id, { + supportedVersions: [this.serverProtocolVersion], + capabilities: { tools: {} }, + serverInfo: { name: 'loopback-server', version: '3.1.4' } + }); + break; + } + case 'tools/list': { + const tools = [{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]; + this.respond( + message.id, + modern + ? // The 2026 wire shape carries the result discriminator and the cacheable-result fields. + ({ resultType: 'complete', ttlMs: 0, cacheScope: 'public', tools } as unknown as Result) + : { tools } + ); + break; + } + default: { + break; + } } } @@ -1560,29 +1584,38 @@ class LoopbackTransport implements Transport { verifies('transport:custom:client-connect', async ({ protocolVersion }: TestArgs) => { // The body supplies its own consumer-implemented Transport, so the matrix transport arg is unused by design. + // On 2025-era cells the handshake is the plain initialize exchange; on 2026-era cells it is the + // server/discover negotiation (a 2026 revision is never negotiated via initialize), which the client opts + // into by pinning the cell's revision. + const modern = protocolVersion >= '2026-07-28'; const customTransport = new LoopbackTransport(protocolVersion); - const client = newClient(); + const client = modern + ? new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: protocolVersion } } }) + : newClient(); const clientOnclose = vi.fn(); client.onclose = clientOnclose; + const handshake = modern ? ['send:server/discover'] : ['send:initialize', 'send:notifications/initialized']; + const handshakeRequests = modern ? ['server/discover'] : ['initialize']; try { await client.connect(customTransport); - // Protocol installed its callbacks on the consumer object before invoking start(). + // Connect installed callbacks on the consumer object before invoking start(). expect(customTransport.callbacksPresentAtStart).toEqual({ onmessage: true, onclose: true, onerror: true }); // The full handshake ran over the consumer transport, and its canned identity is what the client now reports. - expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized']); + expect(customTransport.events).toEqual(['start', ...handshake]); expect(client.getServerCapabilities()).toEqual({ tools: {} }); expect(client.getServerVersion()).toEqual({ name: 'loopback-server', version: '3.1.4' }); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); // A post-handshake request round-trips through the consumer transport's send(). const listed = await client.listTools(); expect(listed.tools).toEqual([{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]); - expect(customTransport.clientRequests.map(m => m.method)).toEqual(['initialize', 'tools/list']); + expect(customTransport.clientRequests.map(m => m.method)).toEqual([...handshakeRequests, 'tools/list']); await client.close(); // close() reached the consumer transport, and its onclose callback fed back into the client's close handling. - expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized', 'send:tools/list', 'close']); + expect(customTransport.events).toEqual(['start', ...handshake, 'send:tools/list', 'close']); expect(clientOnclose).toHaveBeenCalledTimes(1); expect(client.transport).toBeUndefined(); } finally { diff --git a/test/e2e/scenarios/raw-result-type.test.ts b/test/e2e/scenarios/raw-result-type.test.ts index 35956810fe..ee5b454763 100644 --- a/test/e2e/scenarios/raw-result-type.test.ts +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -4,7 +4,9 @@ * postures are ruled per era by Q1-SD3). * * A raw relay server (no SDK Server involved) answers tools/call with hand - * built bodies. The negotiated protocol version selects the wire era: + * built bodies. The negotiated protocol version selects the wire era; the + * modern arms negotiate it through the real path (versionNegotiation + + * server/discover — a 2026 revision is never negotiated via initialize): * * - Negotiated 2026-07-28: `resultType` is the REQUIRED discriminator. An * `input_required` body surfaces the discriminated kind as a typed local @@ -55,6 +57,15 @@ function makeResponder(toolCallBody: unknown) { const requested = (request.params as { protocolVersion?: string } | undefined)?.protocolVersion ?? LATEST_PROTOCOL_VERSION; return initializeResult(requested); } + if (request.method === 'server/discover') { + // The modern handshake: the relay advertises the draft revision so a + // negotiating client selects it (no initialize on that path). + return { + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + serverInfo: { name: 'raw-input-required-server', version: '0' } + }; + } if (request.method === 'tools/call') return toolCallBody; return {}; }; @@ -116,10 +127,14 @@ verifies('typescript:client:raw-result-type-first', async ({ transport }: TestAr } } - // ---- Modern negotiation: the client opts into the draft revision, the - // relay echoes it back → 2026 era → V-1 discrimination in the codec. ---- + // ---- Modern negotiation: the client pins the draft revision, the relay + // advertises it via server/discover → 2026 era → V-1 discrimination in + // the codec. ---- { - const client = new Client({ name: 'raw-result-type-client', version: '0' }, { supportedProtocolVersions: ['2026-07-28'] }); + const client = new Client( + { name: 'raw-result-type-client', version: '0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } } + ); await (transport === 'inMemory' ? connectInMemory(client, INPUT_REQUIRED_BODY) : connectStreamableHttp(client, INPUT_REQUIRED_BODY)); @@ -141,7 +156,10 @@ verifies('typescript:client:raw-result-type-first', async ({ transport }: TestAr // surfaced as a typed error naming it (Q1-SD3 i — the absent⇒complete // bridge applies only to earlier-revision servers). ---- { - const client = new Client({ name: 'raw-result-type-client', version: '0' }, { supportedProtocolVersions: ['2026-07-28'] }); + const client = new Client( + { name: 'raw-result-type-client', version: '0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } } + ); await (transport === 'inMemory' ? connectInMemory(client, ABSENT_RESULT_TYPE_BODY) : connectStreamableHttp(client, ABSENT_RESULT_TYPE_BODY)); diff --git a/test/e2e/scenarios/transport-raw.test.ts b/test/e2e/scenarios/transport-raw.test.ts index 5645df0181..bef7300943 100644 --- a/test/e2e/scenarios/transport-raw.test.ts +++ b/test/e2e/scenarios/transport-raw.test.ts @@ -17,7 +17,12 @@ import { fileURLToPath } from 'node:url'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { CallToolResultSchema, InitializeResultSchema, JSONRPCResultResponseSchema } from '@modelcontextprotocol/core'; +import { + CallToolResultSchema, + InitializeResultSchema, + JSONRPCResultResponseSchema, + LATEST_PROTOCOL_VERSION +} from '@modelcontextprotocol/core'; import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/server'; import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; import { expect, vi } from 'vitest'; @@ -45,6 +50,17 @@ function initializeRequest(id: number, protocolVersion: string): JSONRPCRequest const INITIALIZED_NOTIFICATION: JSONRPCNotification = { jsonrpc: '2.0', method: 'notifications/initialized' }; +/** + * The protocol version a real SDK server negotiates for a raw `initialize` + * naming `requested`: 2026-era revisions are never negotiated via the legacy + * initialize handshake (they are only selected through `server/discover`), so + * the server answers with its latest legacy version instead of echoing the + * request. + */ +function expectedNegotiatedVersion(requested: string): string { + return requested >= '2026-07-28' ? LATEST_PROTOCOL_VERSION : requested; +} + /** Hand-built tools/call request for the echo tool exposed by both real servers used below. */ function echoCallRequest(id: number): JSONRPCRequest { return { jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'echo', arguments: { text: 'relayed raw' } } }; @@ -158,7 +174,12 @@ async function rawRelayStdio(protocolVersion: string): Promise { await transport.send(initializeRequest(1, protocolVersion)); // Generous first wait: tsx compiles the fixture inside the freshly spawned child before it can answer. await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 10_000, interval: 25 }); - expectInitializeResponse(defined(received[0], 'initialize response'), 1, protocolVersion, 'stdio-echo-server'); + expectInitializeResponse( + defined(received[0], 'initialize response'), + 1, + expectedNegotiatedVersion(protocolVersion), + 'stdio-echo-server' + ); // Forward the rest of a relay's traffic by hand: initialized notification, then a tools/call. await transport.send(INITIALIZED_NOTIFICATION); @@ -206,7 +227,12 @@ async function rawRelayStreamableHttp(protocolVersion: string, stateless: boolea expect(records).toEqual([{ method: 'POST' }]); await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 5000, interval: 10 }); - expectInitializeResponse(defined(received[0], 'initialize response'), 1, protocolVersion, 'raw-relay-http-server'); + expectInitializeResponse( + defined(received[0], 'initialize response'), + 1, + expectedNegotiatedVersion(protocolVersion), + 'raw-relay-http-server' + ); // Forward the rest of a relay's traffic by hand: initialized notification, then a tools/call. await transport.send(INITIALIZED_NOTIFICATION); diff --git a/test/e2e/types.ts b/test/e2e/types.ts index c7ff6bdd80..d10ab18fca 100644 --- a/test/e2e/types.ts +++ b/test/e2e/types.ts @@ -14,7 +14,7 @@ export const KNOWN_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const; export type SpecVersion = (typeof KNOWN_SPEC_VERSIONS)[number]; /** The spec versions cells are registered for (the active matrix axis). */ -export const ALL_SPEC_VERSIONS = ['2025-11-25'] as const satisfies readonly SpecVersion[]; +export const ALL_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const satisfies readonly SpecVersion[]; /** * Arguments every test body receives. Expand with new matrix axes here so diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 89ea643edb..8de980f16a 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -6,6 +6,7 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, + setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { McpServer, Server } from '@modelcontextprotocol/server'; @@ -174,10 +175,13 @@ test('should restore negotiated protocol version on transport when reconnecting /*** * Test: The negotiated protocol version (and with it the wire era) is connection state — it must * not survive into a fresh connect. A client whose previous connection negotiated the modern - * revision (2026-07-28) must still be able to run a FRESH initialize handshake: `initialize` is - * legacy-era vocabulary by definition (it is physically absent from the modern registry), so a - * negotiated version left over from the dead connection would otherwise kill the handshake - * locally before it reaches the transport. + * revision (2026-07-28) via server/discover must still be able to run a FRESH legacy initialize + * handshake: `initialize` is legacy-era vocabulary by definition (it is physically absent from + * the modern registry), so a negotiated version left over from the dead connection would + * otherwise kill the handshake locally before it reaches the transport. + * + * The modern era is reached through the real negotiation path (versionNegotiation + the + * server/discover probe) — never via initialize, which only negotiates legacy versions. */ test('should run a fresh initialize handshake after close() when the previous connection negotiated the modern era', async () => { const MODERN_REVISION = '2026-07-28'; @@ -187,24 +191,43 @@ test('should run a fresh initialize handshake after close() when the previous co const server = new Server({ name: 'modern server', version: '1.0' }, { capabilities: {}, supportedProtocolVersions }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); + // Stand-in for the modern-era server entry (instance binding): mark the server instance + // as serving the modern era so it can answer the client's server/discover probe. + setNegotiatedProtocolVersion(server, MODERN_REVISION); await client.connect(clientTransport); }; - const client = new Client({ name: 'test client', version: '1.0' }, { supportedProtocolVersions }); + const connectLegacy = async (client: Client) => { + const server = new Server({ name: 'legacy server', version: '1.0' }, { capabilities: {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }; + + // The client opts into negotiation: server/discover probe first, legacy initialize fallback. + const client = new Client({ name: 'test client', version: '1.0' }, { supportedProtocolVersions, versionNegotiation: { mode: 'auto' } }); - // First connection negotiates the modern revision: the instance now speaks the modern wire era. + // First connection negotiates the modern revision via server/discover: the instance now + // speaks the modern wire era. await connectModern(client); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); await client.close(); - // Fresh connect (new transport, no sessionId): the stale negotiated version is cleared, the - // handshake rides the pre-negotiation bootstrap pin (legacy era), and the connection - // can re-negotiate the modern revision. + // Fresh connect (new transport, no sessionId): the stale negotiated version is cleared and + // the connection re-negotiates from scratch — modern again here. await connectModern(client); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); await client.close(); + + // A fresh connect against a legacy-only server still runs the legacy initialize fallback: + // a leftover modern negotiated version would kill `initialize` locally (it is physically + // absent from the modern registry). + await connectLegacy(client); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await client.close(); }); /*** diff --git a/test/integration/test/client/discoverRoundtrip.test.ts b/test/integration/test/client/discoverRoundtrip.test.ts new file mode 100644 index 0000000000..cdf551d60e --- /dev/null +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -0,0 +1,170 @@ +/** + * Discover round-trip: a pin-mode 2026 client completes `server/discover` → + * version selection against a modern server over real HTTP, plus the + * era-aware counter-offer end to end (a legacy client against a server whose + * supported list carries a 2026 revision never sees a 2026 version string). + * + * Era is instance state on the server: an inbound `server/discover` is served + * only by a modern-era instance (the method is physically absent from the + * legacy registry). Production binding of modern-era instances belongs to the + * server-side entry that classifies inbound traffic; until it lands these + * tests bind the instance through the package-internal hook it will use. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SdkError, SdkErrorCode, setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +const MODERN = '2026-07-28'; +const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS]; + +function recordingFetch() { + const bodies: string[] = []; + const fetchFn: typeof fetch = async (input, init) => { + if (typeof init?.body === 'string') bodies.push(init.body); + return fetch(input, init); + }; + return { bodies, fetchFn }; +} + +describe('server/discover round-trip against a modern server', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + async function startServer(options: { modernEraInstance: boolean }) { + const httpServer: HttpServer = createServer(); + const mcpServer = new McpServer( + { name: 'dual-era-server', version: '2.0.0' }, + { + capabilities: { tools: { listChanged: true } }, + supportedProtocolVersions: DUAL_ERA_VERSIONS, + instructions: 'dual era' + } + ); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await mcpServer.connect(serverTransport); + if (options.modernEraInstance) { + // Stand-in for the server-side entry (instance binding): mark the + // instance as serving the modern era so it can answer the probe. + setNegotiatedProtocolVersion(mcpServer.server, MODERN); + } + httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + httpServer.close(); + }); + return baseUrl; + } + + it('pin-mode 2026 client: server/discover → version selection, no initialize ever sent', async () => { + const baseUrl = await startServer({ modernEraInstance: true }); + const { bodies, fetchFn } = recordingFetch(); + + const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: fetchFn })); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(client.getServerVersion()).toEqual({ name: 'dual-era-server', version: '2.0.0' }); + expect(client.getInstructions()).toBe('dual era'); + // The advertisement excludes listChanged-class capabilities, visible end to end. + expect(client.getServerCapabilities()).toEqual({ tools: {} }); + + expect(bodies.some(b => b.includes('"initialize"'))).toBe(false); + expect(bodies[0]).toContain('server/discover'); + }); + + it('auto-mode client selects the modern era on the same server', async () => { + const baseUrl = await startServer({ modernEraInstance: true }); + const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + }); + + it('auto-mode against the same server NOT bound to the modern era falls back to the legacy handshake', async () => { + // A server instance serves the legacy era until it is bound to the + // modern one (binding is owned by the server-side entry); the probe is + // answered -32601 and the client falls back cleanly on the same + // connection. + const baseUrl = await startServer({ modernEraInstance: false }); + const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + const result = await client.callTool({ name: 'echo', arguments: { text: 'fallback' } }); + expect(result.content).toEqual([{ type: 'text', text: 'fallback' }]); + }); + + it('a plain legacy client against a server with a dual-era list never meets a 2026 version string (counter-offer ordering, e2e)', async () => { + const baseUrl = await startServer({ modernEraInstance: false }); + const { fetchFn } = recordingFetch(); + + const responses: string[] = []; + const sniffingFetch: typeof fetch = async (input, init) => { + const response = await fetchFn(input, init); + responses.push( + await response + .clone() + .text() + .catch(() => '') + ); + return response; + }; + + const client = new Client({ name: 'legacy-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: sniffingFetch })); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + const result = await client.callTool({ name: 'echo', arguments: { text: 'legacy' } }); + expect(result.content).toEqual([{ type: 'text', text: 'legacy' }]); + + // The 2026 revision never appears in any response the legacy client received. + for (const body of responses) { + expect(body).not.toContain(MODERN); + } + }); + + it('client.discover() on a legacy-era connection is rejected locally with a typed error', async () => { + // Default (legacy-only) server; the connection negotiates a legacy + // version, on which server/discover does not exist — the request is + // rejected locally before it reaches the wire. (The typed discover() + // round-trip over HTTP completes once every modern request carries the + // per-request _meta envelope.) + const httpServer: HttpServer = createServer(); + const mcpServer = new McpServer({ name: 'legacy-only', version: '1.0.0' }, { capabilities: { tools: {} } }); + const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await mcpServer.connect(serverTransport); + httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + httpServer.close(); + }); + + const client = new Client({ name: 'legacy-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + await expect(client.discover()).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.MethodNotSupportedByProtocolVersion + ); + }); +}); diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts new file mode 100644 index 0000000000..a5aaee0148 --- /dev/null +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -0,0 +1,288 @@ +/** + * Wire-real version negotiation fixtures: the probe against REAL deployed-shape + * servers over real HTTP. + * + * First-contact wire shapes (both deployment flavors): + * - stateless servers answer the probe 400/-32000 with the byte-exact + * "Unsupported protocol version" literal (version header checked, no session), + * - stateful servers answer 400/-32000 session-required free-text (session is + * checked BEFORE version). + * + * Plus: structural fallback hygiene (the auto client's post-probe traffic is + * byte-identical to a plain legacy client's, zero 2026 headers), the typed + * connect errors for outage and HTTP timeout, and the stdio timeout fallback + * (a silent legacy stdio server is detected by the probe timing out and the + * client falls back to initialize on the same pipe). + */ +import { randomUUID } from 'node:crypto'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +/** A fetch wrapper recording every request our client puts on the wire (URL, headers, body) and the raw response (status, body). */ +function recordingFetch() { + const calls: Array<{ + method: string; + headers: Record; + body: string | undefined; + status: number; + responseBody: string; + }> = []; + const fetchFn: typeof fetch = async (input, init) => { + const headers: Record = {}; + for (const [key, value] of new Headers(init?.headers).entries()) { + headers[key.toLowerCase()] = value; + } + const response = await fetch(input, init); + const clone = response.clone(); + const responseBody = await clone.text().catch(() => ''); + calls.push({ + method: init?.method ?? 'GET', + headers, + body: typeof init?.body === 'string' ? init.body : undefined, + status: response.status, + responseBody + }); + return response; + }; + return { calls, fetchFn }; +} + +const NEGOTIATION_HEADERS = ['mcp-protocol-version', 'mcp-method', 'mcp-name'] as const; + +async function setupLegacyServer(stateful: boolean) { + const httpServer: Server = createServer(); + const mcpServer = new McpServer({ name: 'deployed-2025-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + const serverTransport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: stateful ? () => randomUUID() : undefined + }); + await mcpServer.connect(serverTransport); + httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + return { httpServer, mcpServer, serverTransport, baseUrl }; +} + +describe('version negotiation against real legacy servers (wire-real first-contact shapes)', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + async function startLegacy(stateful: boolean) { + const setup = await setupLegacyServer(stateful); + cleanups.push(async () => { + await setup.mcpServer.close().catch(() => {}); + await setup.serverTransport.close().catch(() => {}); + setup.httpServer.close(); + }); + return setup; + } + + it('stateless deployment: the probe meets the 400/-32000 "Unsupported protocol version" literal, then falls back byte-clean', async () => { + const { baseUrl } = await startLegacy(false); + const { calls, fetchFn } = recordingFetch(); + + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(baseUrl, { fetch: fetchFn }); + await client.connect(transport); + cleanups.push(() => client.close()); + + // First contact: the probe POST (body-derived 2026 headers). + const probe = calls[0]!; + expect(probe.headers['mcp-protocol-version']).toBe('2026-07-28'); + expect(probe.headers['mcp-method']).toBe('server/discover'); + // Wire-real shape #1 — the deployed-fleet literal (Q10-L1; consumed as a fixture only). + expect(probe.status).toBe(400); + const probeBody = JSON.parse(probe.responseBody) as { error: { code: number; message: string } }; + expect(probeBody.error.code).toBe(-32_000); + expect(probeBody.error.message).toContain('Bad Request: Unsupported protocol version: 2026-07-28'); + expect(probeBody.error.message).toContain('supported versions:'); + + // Conservative fallback on the same connection. + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + // Fallback hygiene: ZERO 2026 headers on every post-probe request. + for (const call of calls.slice(1)) { + expect(call.headers['mcp-method']).toBeUndefined(); + expect(call.headers['mcp-name']).toBeUndefined(); + const version = call.headers['mcp-protocol-version']; + if (version !== undefined) { + expect(version < '2026').toBe(true); + } + expect(call.body ?? '').not.toContain('2026-07-28'); + } + + // The legacy era works end to end. + const result = await client.callTool({ name: 'echo', arguments: { text: 'hi' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + }); + + it('stateful deployment: the probe meets 400/-32000 session-required free-text (session checked before version), then falls back', async () => { + const { baseUrl } = await startLegacy(true); + const { calls, fetchFn } = recordingFetch(); + + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(baseUrl, { fetch: fetchFn }); + await client.connect(transport); + cleanups.push(() => client.close()); + + // Wire-real shape #2 — stateful servers reject pre-init non-initialize + // POSTs before ever looking at the version header. + const probe = calls[0]!; + expect(probe.status).toBe(400); + const probeBody = JSON.parse(probe.responseBody) as { error: { code: number; message: string } }; + expect(probeBody.error.code).toBe(-32_000); + expect(probeBody.error.message).toBe('Bad Request: Server not initialized'); + + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + const result = await client.callTool({ name: 'echo', arguments: { text: 'stateful' } }); + expect(result.content).toEqual([{ type: 'text', text: 'stateful' }]); + }); + + it('diff-asserted fallback ≡ this client’s own plain legacy connect under identical ClientOptions', async () => { + const { baseUrl } = await startLegacy(false); + + const auto = recordingFetch(); + const autoClient = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await autoClient.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: auto.fetchFn })); + cleanups.push(() => autoClient.close()); + await autoClient.callTool({ name: 'echo', arguments: { text: 'x' } }); + + const plain = recordingFetch(); + const plainClient = new Client({ name: 'neg-client', version: '1.0.0' }); + await plainClient.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: plain.fetchFn })); + cleanups.push(() => plainClient.close()); + await plainClient.callTool({ name: 'echo', arguments: { text: 'x' } }); + + // Drop the probe exchange; everything after it must be identical to the + // plain client: same POST bodies (including the initialize body version) + // and the same headers (no clearing artifacts, no extras). + const autoPosts = auto.calls.filter(c => c.method === 'POST').slice(1); + const plainPosts = plain.calls.filter(c => c.method === 'POST'); + expect(autoPosts.length).toBe(plainPosts.length); + for (const [i, plainPost] of plainPosts.entries()) { + expect(autoPosts[i]!.body).toBe(plainPost!.body); + expect(autoPosts[i]!.headers).toEqual(plainPost!.headers); + for (const header of NEGOTIATION_HEADERS) { + if (header === 'mcp-protocol-version') continue; // legacy value allowed post-initialize + expect(autoPosts[i]!.headers[header]).toBeUndefined(); + } + } + }); +}); + +describe('typed connect errors (Q12) over real sockets', () => { + it('network outage (nothing listening): typed connect error, never a legacy verdict', async () => { + // Reserve a port, then close it so nothing is listening. + const placeholder = createServer(); + const url = await listenOnRandomPort(placeholder); + await new Promise(resolve => placeholder.close(() => resolve())); + + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(url); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + }); + + it('probe timeout: typed timeout error, no initialize ever sent', async () => { + // A server that accepts the request and never responds. + const hang = createServer(() => { + /* never answer */ + }); + const url = await listenOnRandomPort(hang); + + const { calls, fetchFn } = recordingFetch(); + const client = new Client( + { name: 'neg-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 300 } } } + ); + const transport = new StreamableHTTPClientTransport(url, { fetch: fetchFn }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + + // Probe POSTs only — zero initialize POSTs. + const posts = calls.filter(c => c.method === 'POST'); + expect(posts.every(c => c.headers['mcp-method'] === 'server/discover')).toBe(true); + expect(posts.every(c => (c.body ?? '').includes('server/discover'))).toBe(true); + expect(calls.some(c => (c.body ?? '').includes('"initialize"'))).toBe(false); + + await new Promise(resolve => hang.close(() => resolve())); + await new Promise(resolve => setTimeout(resolve, 50)); + }, 15_000); +}); + +describe('stdio: silent legacy server (probe timeout fallback)', () => { + // The stdio transport's backward-compatibility rule: a probe that gets no + // response within a reasonable timeout indicates a legacy server — some + // legacy servers do not respond to unknown pre-initialize requests at all + // — and the client falls back to initialize on the same pipe. (On HTTP, + // by contrast, a timeout stays a typed connect error; see the test above.) + const SILENT_LEGACY_SERVER_SCRIPT = String.raw` + let buffer = ''; + process.stdin.on('data', chunk => { + buffer += chunk.toString(); + let index; + while ((index = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + if (line.trim() === '') continue; + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + // A legacy server that simply ignores unknown pre-initialize + // requests (server/discover gets NO reply at all) but answers + // the initialize handshake normally. + if (message.method === 'initialize' && message.id !== undefined) { + process.stdout.write( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'silent-legacy-stdio-server', version: '1.0.0' } + } + }) + '\n' + ); + } + } + }); + `; + + it('auto mode: the probe times out, the client falls back to initialize on the same pipe and connects on the legacy era', async () => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['-e', SILENT_LEGACY_SERVER_SCRIPT] + }); + const client = new Client( + { name: 'neg-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 500 } } } + ); + + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + expect(client.getServerVersion()?.name).toBe('silent-legacy-stdio-server'); + } finally { + await client.close(); + } + }, 15_000); +}); From 6a19cc82efddcb588143bb8aad132b98763c070a Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:01:11 +0100 Subject: [PATCH 13/37] feat(server): per-request serving core and inbound validation ladder (#2303) --- packages/core/src/index.ts | 2 + packages/core/src/shared/envelope.ts | 99 +++ .../core/src/shared/inboundClassification.ts | 687 ++++++++++++++++++ .../test/shared/inboundClassification.test.ts | 445 ++++++++++++ .../shared/inboundLadderCellSheet.test.ts | 460 ++++++++++++ packages/server/src/server/invoke.ts | 68 ++ .../server/src/server/perRequestTransport.ts | 404 ++++++++++ .../test/server/eraParityErrorShapes.test.ts | 246 +++++++ .../server/test/server/invokeSeam.test.ts | 139 ++++ .../test/server/perRequestStreaming.test.ts | 251 +++++++ .../test/server/perRequestTransport.test.ts | 386 ++++++++++ 11 files changed, 3187 insertions(+) create mode 100644 packages/core/src/shared/envelope.ts create mode 100644 packages/core/src/shared/inboundClassification.ts create mode 100644 packages/core/test/shared/inboundClassification.test.ts create mode 100644 packages/core/test/shared/inboundLadderCellSheet.test.ts create mode 100644 packages/server/src/server/invoke.ts create mode 100644 packages/server/src/server/perRequestTransport.ts create mode 100644 packages/server/test/server/eraParityErrorShapes.test.ts create mode 100644 packages/server/test/server/invokeSeam.test.ts create mode 100644 packages/server/test/server/perRequestStreaming.test.ts create mode 100644 packages/server/test/server/perRequestTransport.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f5c11a5e0c..51d8204fe4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,8 @@ export * from './auth/errors.js'; export * from './errors/sdkErrors.js'; export * from './shared/auth.js'; export * from './shared/authUtils.js'; +export * from './shared/envelope.js'; +export * from './shared/inboundClassification.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/protocolEras.js'; diff --git a/packages/core/src/shared/envelope.ts b/packages/core/src/shared/envelope.ts new file mode 100644 index 0000000000..3aba586452 --- /dev/null +++ b/packages/core/src/shared/envelope.ts @@ -0,0 +1,99 @@ +/** + * Per-request `_meta` envelope claim helpers (protocol revision 2026-07-28). + * + * Pure, value-returning helpers used by the inbound HTTP classifier + * (`classifyInboundRequest`): claim detection and envelope validation with + * self-identifying issues. The envelope schema itself stays the wire layer's + * single source of truth (`RequestMetaEnvelopeSchema`); this module only maps + * its outcomes into the shapes the validation ladder emits. + * + * Claim detection is deliberately narrow: a message claims the 2026-07-28 + * envelope mechanism if and only if the reserved protocol-version `_meta` key + * is present in `params._meta`. Other reserved keys (client info, client + * capabilities, log level), a bare `progressToken`, or unrelated keys under + * the `io.modelcontextprotocol/` prefix do NOT constitute a claim on their + * own — but once the claim key is present, a malformed envelope is a + * validation error, never a silent fall back to legacy handling. + */ +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../types/constants.js'; +import { RequestMetaEnvelopeSchema } from '../wire/rev2026-07-28/schemas.js'; + +/** A single self-identifying problem found while validating a per-request `_meta` envelope. */ +export interface EnvelopeIssue { + /** + * The envelope key the problem is about: one of the reserved `_meta` keys, + * or a dotted path inside one (e.g. `io.modelcontextprotocol/clientInfo.name`). + */ + key: string; + /** A short description of what is wrong with that key (`missing`, or a validation message). */ + problem: string; +} + +/** The reserved `_meta` keys an envelope must carry (in reporting order). */ +const REQUIRED_ENVELOPE_KEYS: readonly string[] = [PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY]; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** The `_meta` object of a message's params, when present. */ +export function requestMetaOf(params: unknown): Record | undefined { + if (!isPlainObject(params)) return undefined; + const meta = params['_meta']; + return isPlainObject(meta) ? meta : undefined; +} + +/** + * Whether a message's params carry the per-request envelope claim: the + * reserved protocol-version `_meta` key is present (regardless of whether the + * rest of the envelope is valid — validation is a separate, later step). + */ +export function hasEnvelopeClaim(params: unknown): boolean { + const meta = requestMetaOf(params); + return meta !== undefined && PROTOCOL_VERSION_META_KEY in meta; +} + +/** + * The protocol version named by a message's envelope claim, when the claim is + * present and carries a string value. A present claim with a non-string value + * still counts as a claim ({@linkcode hasEnvelopeClaim}); it surfaces as a + * validation issue instead of a version. + */ +export function envelopeClaimVersion(params: unknown): string | undefined { + const meta = requestMetaOf(params); + const value = meta?.[PROTOCOL_VERSION_META_KEY]; + return typeof value === 'string' ? value : undefined; +} + +/** + * Validates a request's `_meta` object as a 2026-07-28 per-request envelope + * and reports problems as self-identifying issues (which key, what problem). + * + * Returns an empty array when the envelope is valid. Missing required keys are + * reported first (as `problem: 'missing'`), then schema violations inside + * present keys, in a stable order. + */ +export function validateEnvelopeMeta(meta: Record): EnvelopeIssue[] { + const issues: EnvelopeIssue[] = []; + + for (const key of REQUIRED_ENVELOPE_KEYS) { + if (!(key in meta)) { + issues.push({ key, problem: 'missing' }); + } + } + + const parsed = RequestMetaEnvelopeSchema.safeParse(meta); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + const path = issue.path.map(String); + const key = path.length > 0 ? path.join('.') : '_meta'; + // Missing required keys were already reported above in canonical order. + if (path.length === 1 && issues.some(existing => existing.key === key && existing.problem === 'missing')) { + continue; + } + issues.push({ key, problem: issue.message }); + } + } + + return issues; +} diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts new file mode 100644 index 0000000000..f731796280 --- /dev/null +++ b/packages/core/src/shared/inboundClassification.ts @@ -0,0 +1,687 @@ +/** + * Inbound HTTP request classification and the inbound validation ladder + * (protocol revision 2026-07-28). + * + * `classifyInboundRequest` is the body-primary era predicate for an HTTP + * entry that serves both protocol eras on one endpoint. It is evaluated + * exactly once, at the entry boundary, on the already-parsed request body: + * + * - `initialize` is a legacy-era request by definition (the modern era has no + * `initialize` handshake) — unless it carries a valid envelope claim naming + * a modern revision, in which case the claim wins and the request is + * classified like any other enveloped request (the modern era then answers + * it with method-not-found, exactly like every other method it does not + * define). + * - A request whose `params._meta` carries the reserved protocol-version key + * claims the per-request envelope mechanism and classifies into the era the + * named revision belongs to (a malformed envelope behind a present claim is + * a validation error, never a silent fall back to legacy handling). + * - A request without a claim is legacy-era traffic. + * - The `MCP-Protocol-Version` header is a cross-check only: it never + * upgrades or downgrades a body-derived classification, and a disagreement + * between header and body is an explicit ladder outcome. + * - Notifications carry no envelope claim of their own under the current + * spec, so for notification POSTs without a body claim the modern header is + * determinative; the `Mcp-Method` header is validated against the body when + * the message classifies modern and is never enforced on legacy traffic. + * A notification that does carry a claim is treated body-primary like a + * request, and a malformed claim is rejected the same way a request's + * malformed claim is — never silently resolved against the header. + * - `GET`/`DELETE` (and any other non-`POST` method) are body-less 2025-era + * session operations: the modern era is `POST`-only, so they are routed to + * legacy serving when it is configured and rejected otherwise. + * - Array (batch) bodies are classified element-wise: an array containing a + * modern-claiming or invalid element is rejected, an all-legacy array is + * legacy traffic unchanged, and a single-element array is still an array. + * + * The classifier returns plain values (it never throws and never touches a + * transport): a routing outcome (`legacy`/`modern`) or a ladder rejection + * carrying the JSON-RPC error to emit and the HTTP status to emit it with. + * Legacy routing outcomes deliberately carry NO `MessageClassification` — + * legacy and hand-wired traffic is never classified, which keeps its + * dispatch behavior byte-identical to today's. + * + * Some ladder cells do not have a settled error code upstream yet (the + * header/body mismatch family: the candidate codes are `-32001`, `-32602` + * and `-32004`; see the note in `test/conformance/expected-failures.yaml`). + * Those outcomes are emitted with a single provisional code and are marked + * `settled: false` so tests and consumers can treat them as parameterized + * rather than pinned. + */ +import { PROTOCOL_VERSION_META_KEY } from '../types/constants.js'; +import { ProtocolErrorCode } from '../types/enums.js'; +import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors.js'; +import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js'; +import type { MessageClassification } from '../types/types.js'; +import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js'; +import { isModernProtocolVersion } from './protocolEras.js'; + +/* ------------------------------------------------------------------------ * + * Classifier input + * ------------------------------------------------------------------------ */ + +/** + * The transport-neutral description of an inbound HTTP request the classifier + * evaluates. The caller (the HTTP entry) reads the body exactly once and + * extracts the two protocol headers; the classifier never touches a request + * object itself. + */ +export interface InboundHttpRequest { + /** The HTTP request method, e.g. `POST`, `GET`, `DELETE`. */ + httpMethod: string; + /** The value of the `MCP-Protocol-Version` header, when present. */ + protocolVersionHeader?: string; + /** The value of the `Mcp-Method` header, when present. */ + mcpMethodHeader?: string; + /** The parsed JSON request body (`undefined` for body-less methods). */ + body?: unknown; +} + +/* ------------------------------------------------------------------------ * + * Classifier outcomes + * ------------------------------------------------------------------------ */ + +/** Why an inbound request was routed to legacy-era serving. */ +export type InboundLegacyRouteReason = + /** Non-`POST` HTTP method: a body-less 2025-era session operation. */ + | 'http-method' + /** An `initialize` request without a valid modern envelope claim — the legacy handshake by definition. */ + | 'initialize' + /** A request without a per-request envelope claim. */ + | 'no-claim' + /** A notification without a body claim or a modern protocol-version header. */ + | 'notification' + /** An all-legacy JSON-RPC batch array. */ + | 'batch' + /** A JSON-RPC response posted to the endpoint (2025-era session traffic). */ + | 'response'; + +/** + * The request is legacy-era traffic. It carries no classification on purpose: + * legacy serving receives it exactly as a hand-wired 2025 transport would. + */ +export interface InboundLegacyRoute { + kind: 'legacy'; + reason: InboundLegacyRouteReason; + /** + * The protocol version the request named, when it named one (an + * `initialize` body's `protocolVersion`, or the `MCP-Protocol-Version` + * header). Used to echo `requested` when legacy serving is not configured. + */ + requestedVersion?: string; +} + +/** The request claims the per-request envelope mechanism and is served on the modern path. */ +export interface InboundModernRoute { + kind: 'modern'; + /** Whether the classified message is a request or a notification. */ + messageKind: 'request' | 'notification'; + /** + * The classification handed to the per-request transport and validated by + * the protocol layer against the serving instance's negotiated era. + */ + classification: MessageClassification; +} + +/** The named steps of the inbound validation ladder, in evaluation order. */ +export type InboundValidationRung = + | 'http-method' + | 'jsonrpc-shape' + | 'era-classification' + | 'envelope' + | 'method-registry' + | 'request-params' + | 'client-capabilities'; + +/** A ladder rejection: the JSON-RPC error to emit and the HTTP status to emit it with. */ +export interface InboundLadderRejection { + kind: 'reject'; + /** The ladder rung that produced the rejection. */ + rung: InboundValidationRung; + /** The cell this rejection corresponds to on the ladder cell sheet (stable identifier for tests). */ + cell: string; + /** The HTTP status the rejection is emitted with. */ + httpStatus: number; + /** The JSON-RPC error code. */ + code: number; + /** The JSON-RPC error message. */ + message: string; + /** Structured error data (recognizers parse this; they never rely on class identity). */ + data?: unknown; + /** + * `false` when the exact error code for this cell is not settled upstream + * yet and the emitted code is provisional. + */ + settled: boolean; +} + +/** The outcome of classifying one inbound HTTP request. */ +export type InboundClassificationOutcome = InboundLegacyRoute | InboundModernRoute | InboundLadderRejection; + +/* ------------------------------------------------------------------------ * + * The validation ladder as data + * ------------------------------------------------------------------------ */ + +/** One rung of the inbound validation ladder. */ +export interface InboundValidationRungDescriptor { + rung: InboundValidationRung; + /** Evaluation order: lower runs first; an earlier rung's outcome wins over a later rung's. */ + order: number; + /** Where the rung is evaluated: at the HTTP entry edge or at protocol dispatch. */ + evaluatedAt: 'edge' | 'dispatch'; + /** The JSON-RPC error codes this rung can produce (empty when the rung only routes). */ + codes: readonly number[]; + /** Conformance scenarios that exercise this rung (where one exists). */ + conformance: readonly string[]; + /** Why the rung sits where it does. */ + rationale: string; +} + +/** + * The inbound validation ladder, expressed as data rather than control flow. + * + * The edge rungs are evaluated by {@linkcode classifyInboundRequest}; the + * dispatch rungs are evaluated by the protocol layer once the classified + * message is injected into a per-request server instance (the era registry + * gate, the envelope requiredness check, per-method params validation, and + * the client-capability check). The order is the precedence: a request that + * fails several rungs is answered by the earliest one. + */ +export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor[] = [ + { + rung: 'http-method', + order: 1, + evaluatedAt: 'edge', + codes: [-32_000], + conformance: [], + rationale: + 'The modern era is POST-only; GET/DELETE are body-less 2025-era session operations and are method-routed to legacy ' + + 'serving (405 when legacy serving is not configured), before any body is read.' + }, + { + rung: 'jsonrpc-shape', + order: 2, + evaluatedAt: 'edge', + codes: [ProtocolErrorCode.InvalidRequest], + conformance: ['server-stateless'], + rationale: + 'The body must be a JSON-RPC request or notification: posted responses and batch arrays containing a modern or ' + + 'invalid element are rejected before classification (element-wise batch rule); all-legacy arrays stay legacy traffic.' + }, + { + rung: 'era-classification', + order: 3, + evaluatedAt: 'edge', + codes: [ProtocolErrorCode.UnsupportedProtocolVersion], + conformance: ['server-stateless', 'http-header-validation', 'http-custom-header-server-validation'], + rationale: + 'Body-primary era classification with the protocol-version header as a cross-check; a header/body disagreement is a ' + + 'distinct outcome whose exact error code is still under discussion upstream (provisional, see expected-failures.yaml).' + }, + { + rung: 'envelope', + order: 4, + evaluatedAt: 'edge', + codes: [ProtocolErrorCode.InvalidParams], + conformance: ['server-stateless'], + rationale: + 'A present envelope claim with a malformed envelope is an invalid-params rejection naming the offending key — never a ' + + 'silent fall back to legacy handling. This is the only place an invalid-params rejection maps to HTTP 400.' + }, + { + rung: 'method-registry', + order: 5, + evaluatedAt: 'dispatch', + codes: [ProtocolErrorCode.MethodNotFound], + conformance: ['server-stateless'], + rationale: + 'Method existence outranks parameter validity: a method absent from the negotiated revision’s registry (or with no ' + + 'handler installed) answers method-not-found before params or capabilities are looked at.' + }, + { + rung: 'request-params', + order: 6, + evaluatedAt: 'dispatch', + codes: [ProtocolErrorCode.InvalidParams], + conformance: [], + rationale: 'Per-method params validation; emitted in-band by the dispatch layer (HTTP 200), never via the ladder status table.' + }, + { + rung: 'client-capabilities', + order: 7, + evaluatedAt: 'dispatch', + codes: [ProtocolErrorCode.MissingRequiredClientCapability], + conformance: ['server-stateless'], + rationale: + 'Capability assertion runs after envelope validation and method resolution, immediately before the handler; the ' + + 'emission itself ships with the capability-policy work and is recorded here for ordering only.' + } +]; + +/* ------------------------------------------------------------------------ * + * HTTP status mapping for ladder-originated errors + * ------------------------------------------------------------------------ */ + +/** + * HTTP status for ladder-originated JSON-RPC error codes. + * + * Keyed on origin, not on the bare code: this table only applies to errors + * the ladder (or a pre-handler protocol gate) produced. Errors produced by + * request handlers — whatever their code — stay in-band on HTTP 200, and are + * never mapped to an HTTP status by this table; in particular `-32603` and + * domain-specific codes never become a blanket 500. + * + * `-32602` (invalid params) deliberately has NO entry: the only invalid-params + * rejection that maps to HTTP 400 is the classifier's own envelope rung + * short-circuit, which carries its HTTP status directly. A dispatch- or + * handler-produced invalid-params error is always in-band. + */ +export const LADDER_ERROR_HTTP_STATUS: Readonly> = { + [ProtocolErrorCode.MethodNotFound]: 404, + [ProtocolErrorCode.UnsupportedProtocolVersion]: 400, + [ProtocolErrorCode.MissingRequiredClientCapability]: 400, + [-32_001]: 400 +}; + +/** + * The HTTP status to answer a JSON-RPC error with, keyed on the error's + * origin. `in-band` errors (anything produced by a request handler) are + * always HTTP 200 — the JSON-RPC error response is the payload, not an HTTP + * failure. `ladder` errors map through {@linkcode LADDER_ERROR_HTTP_STATUS}. + */ +export function httpStatusForErrorCode(code: number, origin: 'ladder' | 'in-band'): number { + if (origin === 'in-band') return 200; + return LADDER_ERROR_HTTP_STATUS[code] ?? 400; +} + +/* ------------------------------------------------------------------------ * + * Provisional cells + * ------------------------------------------------------------------------ */ + +/** + * The error code emitted for header/body cross-check mismatches (the + * protocol-version header disagreeing with the body classification, and the + * `Mcp-Method` header disagreeing with the body method). + * + * The exact code for these cells is still under discussion upstream — the + * candidates are `-32001`, `-32602` and `-32004` (see the note in + * `test/conformance/expected-failures.yaml`). Until a published conformance + * release settles them, the ladder emits the protocol-layer era-mismatch code + * and marks the outcome `settled: false`. + */ +export const PROVISIONAL_CROSS_CHECK_MISMATCH_CODE: number = ProtocolErrorCode.UnsupportedProtocolVersion; + +/* ------------------------------------------------------------------------ * + * The classifier + * ------------------------------------------------------------------------ */ + +function rejection( + rung: InboundValidationRung, + cell: string, + httpStatus: number, + error: ProtocolError, + settled: boolean +): InboundLadderRejection { + return { + kind: 'reject', + rung, + cell, + httpStatus, + code: error.code, + message: error.message, + ...(error.data !== undefined && { data: error.data }), + settled + }; +} + +function crossCheckMismatch(cell: string, header: string, body: string): InboundLadderRejection { + return rejection( + 'era-classification', + cell, + 400, + new ProtocolError(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE, `Bad Request: the request headers and body disagree: ${body}`, { + mismatch: { header, body } + }), + false + ); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function classificationForClaim(claimedVersion: string | undefined): MessageClassification { + if (claimedVersion === undefined) { + return { era: 'modern' }; + } + return { era: isModernProtocolVersion(claimedVersion) ? 'modern' : 'legacy', revision: claimedVersion }; +} + +/** + * Whether a request's params carry a per-request envelope claim that is both + * well-formed and names a modern protocol revision. + * + * Used by the `initialize` precedence rule: only such a claim overrides the + * `initialize` ⇒ legacy-handshake classification — a request carrying a valid + * modern envelope is a modern request regardless of its method name, and the + * modern era then answers `initialize` exactly like any other method it does + * not define (method-not-found). A malformed claim, or one naming a pre-2026 + * revision, keeps the legacy-handshake routing unchanged. + */ +function carriesValidModernEnvelopeClaim(params: unknown): boolean { + if (!hasEnvelopeClaim(params)) { + return false; + } + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion === undefined || !isModernProtocolVersion(claimedVersion)) { + return false; + } + const meta = requestMetaOf(params); + return meta !== undefined && validateEnvelopeMeta(meta).length === 0; +} + +function classifyBatch(body: readonly unknown[]): InboundClassificationOutcome { + if (body.length === 0) { + return rejection( + 'jsonrpc-shape', + 'empty-batch', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: empty JSON-RPC batch'), + true + ); + } + for (const element of body) { + const params = isPlainObject(element) ? element['params'] : undefined; + if (hasEnvelopeClaim(params)) { + // Element-wise rule: a single modern element makes the whole array + // unservable — modern requests are single-message POSTs, and the + // legacy path must never serve an envelope-claiming element. + return rejection( + 'jsonrpc-shape', + 'batch-with-modern-element', + 400, + new ProtocolError( + ProtocolErrorCode.InvalidRequest, + 'Bad Request: JSON-RPC batches may not contain requests for protocol revision 2026-07-28 or later' + ), + true + ); + } + const valid = + isJSONRPCRequest(element) || + isJSONRPCNotification(element) || + isJSONRPCResultResponse(element) || + isJSONRPCErrorResponse(element); + if (!valid) { + return rejection( + 'jsonrpc-shape', + 'batch-with-invalid-element', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: JSON-RPC batch contains an invalid message'), + true + ); + } + } + // All elements are legacy-era messages: legacy serving takes the array unchanged. + return { kind: 'legacy', reason: 'batch' }; +} + +function classifyRequestBody(request: InboundHttpRequest, body: Record): InboundClassificationOutcome { + const params = body['params']; + const method = body['method'] as string; + const headerVersion = request.protocolVersionHeader; + const headerNamesModern = headerVersion !== undefined && isModernProtocolVersion(headerVersion); + + // `initialize` is the legacy handshake by definition — unless the request + // carries a valid envelope claim naming a modern revision, in which case + // the claim wins: the request is classified like any other enveloped + // request and served on the modern path, where the modern registry answers + // `initialize` as method-not-found like every other method it does not + // define. A malformed or absent claim, or a claim naming a pre-2026 + // revision, keeps the legacy-handshake classification below. + if (method === 'initialize' && !carriesValidModernEnvelopeClaim(params)) { + if (headerNamesModern) { + return crossCheckMismatch( + 'initialize-with-modern-header', + headerVersion, + 'an initialize request (legacy handshake) was sent with a modern MCP-Protocol-Version header' + ); + } + const requestedVersion = + isPlainObject(params) && typeof params['protocolVersion'] === 'string' ? params['protocolVersion'] : undefined; + return { kind: 'legacy', reason: 'initialize', ...(requestedVersion !== undefined && { requestedVersion }) }; + } + + if (hasEnvelopeClaim(params)) { + // A present claim is validated, never silently ignored: a malformed + // envelope behind the claim is an invalid-params rejection naming the + // offending key, not a fall back to legacy handling. + const meta = requestMetaOf(params); + const issues = meta === undefined ? [] : validateEnvelopeMeta(meta); + const firstIssue = issues[0]; + if (firstIssue !== undefined) { + return rejection( + 'envelope', + 'envelope-invalid', + 400, + new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid _meta envelope for protocol revision 2026-07-28: ${firstIssue.key}: ${firstIssue.problem}`, + { envelope: firstIssue } + ), + true + ); + } + + const claimedVersion = envelopeClaimVersion(params); + if (headerVersion !== undefined && claimedVersion !== undefined && headerVersion !== claimedVersion) { + return crossCheckMismatch( + 'header-body-version-mismatch', + headerVersion, + `the body envelope names protocol version ${claimedVersion} but the MCP-Protocol-Version header names ${headerVersion}` + ); + } + if (request.mcpMethodHeader !== undefined && request.mcpMethodHeader !== method) { + return crossCheckMismatch( + 'method-header-mismatch', + request.mcpMethodHeader, + `the body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}` + ); + } + return { kind: 'modern', messageKind: 'request', classification: classificationForClaim(claimedVersion) }; + } + + // No claim: legacy-era traffic. The header is a cross-check only — a + // modern header on a claim-less body is a disagreement, not an upgrade. + if (headerNamesModern) { + return crossCheckMismatch( + 'modern-header-without-claim', + headerVersion, + 'the MCP-Protocol-Version header names a modern protocol revision but the request body carries no _meta envelope claim' + ); + } + return { kind: 'legacy', reason: 'no-claim', ...(headerVersion !== undefined && { requestedVersion: headerVersion }) }; +} + +function classifyNotificationBody(request: InboundHttpRequest, body: Record): InboundClassificationOutcome { + const params = body['params']; + const method = body['method'] as string; + const headerVersion = request.protocolVersionHeader; + const headerNamesModern = headerVersion !== undefined && isModernProtocolVersion(headerVersion); + + if (hasEnvelopeClaim(params)) { + // Body-primary even for notifications: a body claim wins over the + // header, and a disagreement between them is rejected rather than + // letting either signal silently pick the serving path. + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion === undefined) { + // The claim key is present but its value is malformed (not a + // string). Validated exactly like a request claim: an + // invalid-params rejection naming the offending key — never a + // silent win against (or loss to) a disagreeing header. + const meta = requestMetaOf(params); + const issues = meta === undefined ? [] : validateEnvelopeMeta(meta); + const claimIssue = issues.find(issue => issue.key === PROTOCOL_VERSION_META_KEY) ?? { + key: PROTOCOL_VERSION_META_KEY, + problem: 'expected a protocol version string' + }; + return rejection( + 'envelope', + 'notification-envelope-invalid', + 400, + new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid _meta envelope for protocol revision 2026-07-28: ${claimIssue.key}: ${claimIssue.problem}`, + { envelope: claimIssue } + ), + true + ); + } + if (headerVersion !== undefined && headerVersion !== claimedVersion) { + return crossCheckMismatch( + 'notification-header-body-version-mismatch', + headerVersion, + `the notification envelope names protocol version ${claimedVersion} but the MCP-Protocol-Version header names ${headerVersion}` + ); + } + const classification = classificationForClaim(claimedVersion); + if (classification.era === 'modern' && request.mcpMethodHeader !== undefined && request.mcpMethodHeader !== method) { + return crossCheckMismatch( + 'notification-method-header-mismatch', + request.mcpMethodHeader, + `the notification body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}` + ); + } + return { kind: 'modern', messageKind: 'notification', classification }; + } + + // Notifications carry no body claim under the current spec, so the + // protocol-version header is determinative for them: a modern header + // routes the notification to modern serving; a missing or legacy header + // keeps it legacy traffic. The Mcp-Method header is validated only when + // the notification classifies modern — it is never enforced on legacy + // notifications. + if (headerNamesModern) { + if (request.mcpMethodHeader !== undefined && request.mcpMethodHeader !== method) { + return crossCheckMismatch( + 'notification-method-header-mismatch', + request.mcpMethodHeader, + `the notification body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}` + ); + } + return { + kind: 'modern', + messageKind: 'notification', + classification: { era: 'modern', revision: headerVersion } + }; + } + return { kind: 'legacy', reason: 'notification', ...(headerVersion !== undefined && { requestedVersion: headerVersion }) }; +} + +/** + * Classifies one inbound HTTP request for dual-era serving. + * + * The body-primary predicate, evaluated once at the entry boundary: see the + * module documentation for the rules. Returns a routing outcome (`legacy` or + * `modern`) or a ladder rejection; it never throws. + */ +export function classifyInboundRequest(request: InboundHttpRequest): InboundClassificationOutcome { + if (request.httpMethod.toUpperCase() !== 'POST') { + // Body-less 2025-era session operations (and any other non-POST + // method): the modern era is POST-only. + return { kind: 'legacy', reason: 'http-method' }; + } + + const body = request.body; + if (Array.isArray(body)) { + return classifyBatch(body); + } + if (isJSONRPCResultResponse(body) || isJSONRPCErrorResponse(body)) { + // Posted responses are 2025-era session traffic (replies to + // server-initiated requests over a session); the modern era has no + // such channel. + return { kind: 'legacy', reason: 'response' }; + } + if (isPlainObject(body) && isJSONRPCRequest(body)) { + return classifyRequestBody(request, body); + } + if (isPlainObject(body) && isJSONRPCNotification(body)) { + return classifyNotificationBody(request, body); + } + return rejection( + 'jsonrpc-shape', + 'invalid-json-rpc-body', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: the request body is not a valid JSON-RPC message'), + true + ); +} + +/* ------------------------------------------------------------------------ * + * Modern-only (strict) mapping of legacy routes + * ------------------------------------------------------------------------ */ + +/** + * The rejection a modern-only endpoint (no legacy serving configured) + * answers a legacy-classified request with. + * + * - Envelope-less requests (including `initialize`) are answered with the + * unsupported-protocol-version error carrying the endpoint's supported + * versions and echoing the version the request named (when it named one — + * `requested` is omitted rather than fabricated when the request named no + * version at all), so a legacy client can discover what the endpoint serves + * from the error alone. (This cell shares its numeric code with the + * still-disputed mismatch cells above, but its own outcome is settled.) + * - Posted responses and batch arrays are invalid requests on the modern era. + * - Non-`POST` methods are not allowed. + * - Legacy-classified notifications return `undefined`: the caller answers + * 202 with no body and does not dispatch the notification (accept-and-drop). + */ +export function modernOnlyStrictRejection( + route: InboundLegacyRoute, + supportedVersions: readonly string[] +): InboundLadderRejection | undefined { + switch (route.reason) { + case 'http-method': { + return rejection('http-method', 'modern-only-method-not-allowed', 405, new ProtocolError(-32_000, 'Method not allowed.'), true); + } + case 'batch': { + return rejection( + 'jsonrpc-shape', + 'modern-only-batch-not-supported', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: JSON-RPC batches are not supported by this endpoint'), + true + ); + } + case 'response': { + return rejection( + 'jsonrpc-shape', + 'modern-only-response-post', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: JSON-RPC responses cannot be posted to this endpoint'), + true + ); + } + case 'notification': { + return undefined; + } + case 'initialize': + case 'no-claim': { + // `requested` reflects what the request actually named (an + // initialize body's `protocolVersion` or the protocol-version + // header); when the request named no version at all the field is + // omitted rather than fabricated. + const requested = route.requestedVersion; + const error = + requested === undefined + ? new ProtocolError( + ProtocolErrorCode.UnsupportedProtocolVersion, + 'Unsupported protocol version: the request did not name a protocol version', + { supported: [...supportedVersions] } + ) + : new UnsupportedProtocolVersionError({ supported: [...supportedVersions], requested }); + return rejection('era-classification', 'modern-only-missing-envelope', 400, error, true); + } + } +} diff --git a/packages/core/test/shared/inboundClassification.test.ts b/packages/core/test/shared/inboundClassification.test.ts new file mode 100644 index 0000000000..30d21c94e5 --- /dev/null +++ b/packages/core/test/shared/inboundClassification.test.ts @@ -0,0 +1,445 @@ +/** + * Unit tests for the inbound HTTP classifier (`classifyInboundRequest`) and + * the envelope claim helpers: the body-primary era predicate, claim + * detection, envelope validation with self-identifying issues, the header + * cross-checks, notification routing, element-wise batch classification, and + * the modern-only (strict) rejection mapping. + * + * Cells whose exact error code is still under discussion upstream (the + * header/body mismatch family) are asserted as parameterized: the outcome is + * pinned (a rejection, marked unsettled), the code is asserted to be the + * provisional constant and a member of the candidate set — never a hard-coded + * literal of its own. + */ +import { describe, expect, test } from 'vitest'; + +import { hasEnvelopeClaim, validateEnvelopeMeta } from '../../src/shared/envelope.js'; +import type { InboundHttpRequest, InboundLegacyRoute } from '../../src/shared/inboundClassification.js'; +import { + classifyInboundRequest, + modernOnlyStrictRejection, + PROVISIONAL_CROSS_CHECK_MISMATCH_CODE +} from '../../src/shared/inboundClassification.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; + +const MODERN_REVISION = '2026-07-28'; +const MISMATCH_CODE_CANDIDATES = [-32_001, -32_602, -32_004]; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'classifier-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const modernToolsCall = (meta: Record = ENVELOPE) => ({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: meta } +}); + +const legacyToolsList = () => ({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + +const initializeRequest = (protocolVersion = '2025-06-18') => ({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +const notification = (method = 'notifications/initialized', meta?: Record) => ({ + jsonrpc: '2.0', + method, + ...(meta === undefined ? {} : { params: { _meta: meta } }) +}); + +const post = (body: unknown, headers: { protocolVersion?: string; mcpMethod?: string } = {}): InboundHttpRequest => ({ + httpMethod: 'POST', + body, + ...(headers.protocolVersion !== undefined && { protocolVersionHeader: headers.protocolVersion }), + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }) +}); + +const expectMismatch = (outcome: ReturnType, cell: string) => { + expect(outcome.kind).toBe('reject'); + if (outcome.kind !== 'reject') return; + expect(outcome.cell).toBe(cell); + expect(outcome.rung).toBe('era-classification'); + expect(outcome.httpStatus).toBe(400); + // Parameterized: the exact code for the mismatch family is not settled + // upstream. The classifier emits the provisional constant; assert set + // membership rather than a literal of our own. + expect(outcome.settled).toBe(false); + expect(outcome.code).toBe(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE); + expect(MISMATCH_CODE_CANDIDATES).toContain(outcome.code); +}; + +describe('envelope claim detection (claim = the reserved protocol-version key)', () => { + test('a progress-token-only _meta is not a claim', () => { + expect(hasEnvelopeClaim({ _meta: { progressToken: 'token-1' } })).toBe(false); + }); + + test('client info / client capabilities alone are not a claim', () => { + expect( + hasEnvelopeClaim({ + _meta: { [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, [CLIENT_CAPABILITIES_META_KEY]: {} } + }) + ).toBe(false); + }); + + test('stray reserved-prefix keys are ignored by claim detection', () => { + expect(hasEnvelopeClaim({ _meta: { 'io.modelcontextprotocol/somethingElse': true } })).toBe(false); + }); + + test('the protocol-version key alone is a claim, even with a non-string value', () => { + expect(hasEnvelopeClaim({ _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } })).toBe(true); + }); +}); + +describe('envelope validation issues are self-identifying (key + problem)', () => { + test('missing required keys are reported in canonical order', () => { + const issues = validateEnvelopeMeta({ [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }); + expect(issues.map(issue => issue.key)).toEqual([CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY]); + expect(issues.every(issue => issue.problem === 'missing')).toBe(true); + }); + + test('a malformed value inside a present key names the key', () => { + const issues = validateEnvelopeMeta({ ...ENVELOPE, [CLIENT_INFO_META_KEY]: { version: '1.0.0' } }); + expect(issues.length).toBeGreaterThan(0); + expect(issues[0]?.key).toContain(CLIENT_INFO_META_KEY); + expect(issues[0]?.problem).not.toBe('missing'); + }); + + test('a complete, well-formed envelope produces no issues', () => { + expect(validateEnvelopeMeta(ENVELOPE)).toEqual([]); + }); +}); + +describe('body-primary era predicate', () => { + test('an envelope-claiming request with a matching header classifies modern', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: MODERN_REVISION })); + expect(outcome).toMatchObject({ + kind: 'modern', + messageKind: 'request', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + }); + + test('a header-stripped request still classifies modern from the body claim alone', () => { + // Robustness to proxies/CDNs stripping the MCP-Protocol-Version header: + // the body claim is primary. + const outcome = classifyInboundRequest(post(modernToolsCall())); + expect(outcome).toMatchObject({ + kind: 'modern', + messageKind: 'request', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + }); + + test('a claim-less request is legacy traffic and carries no classification', () => { + const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'no-claim', requestedVersion: '2025-06-18' }); + expect('classification' in outcome).toBe(false); + }); + + test('initialize is the legacy handshake by definition', () => { + const outcome = classifyInboundRequest(post(initializeRequest('2025-03-26'))); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'initialize', requestedVersion: '2025-03-26' }); + }); + + test('an initialize carrying a valid modern envelope claim classifies modern (the claim wins over the handshake rule)', () => { + // Body-primary: no headers at all, the valid claim alone decides. The + // modern path then answers `initialize` as method-not-found, exactly + // like every other method the modern revision does not define. + const body = { jsonrpc: '2.0', id: 7, method: 'initialize', params: { _meta: ENVELOPE } }; + expect(classifyInboundRequest(post(body))).toMatchObject({ + kind: 'modern', + messageKind: 'request', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + + // The same request with conformant standard headers (the wire shape a + // modern client actually sends) classifies the same way. + const withHeaders = classifyInboundRequest(post(body, { protocolVersion: MODERN_REVISION, mcpMethod: 'initialize' })); + expect(withHeaders).toMatchObject({ kind: 'modern', classification: { era: 'modern', revision: MODERN_REVISION } }); + }); + + test('an initialize with a malformed envelope claim keeps the legacy-handshake classification', () => { + const body = { + jsonrpc: '2.0', + id: 7, + method: 'initialize', + params: { protocolVersion: '2025-06-18', _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } + }; + expect(classifyInboundRequest(post(body))).toMatchObject({ kind: 'legacy', reason: 'initialize', requestedVersion: '2025-06-18' }); + }); + + test('an initialize whose valid envelope claim names a pre-2026 revision keeps the legacy-handshake classification', () => { + const meta = { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }; + const body = { jsonrpc: '2.0', id: 7, method: 'initialize', params: { _meta: meta } }; + expect(classifyInboundRequest(post(body))).toMatchObject({ kind: 'legacy', reason: 'initialize' }); + }); + + test('GET and DELETE are method-routed legacy session operations', () => { + expect(classifyInboundRequest({ httpMethod: 'GET' })).toMatchObject({ kind: 'legacy', reason: 'http-method' }); + expect(classifyInboundRequest({ httpMethod: 'DELETE' })).toMatchObject({ kind: 'legacy', reason: 'http-method' }); + }); + + test('a claim naming a legacy revision keeps the named revision on the classification', () => { + // The envelope mechanism naming a pre-2026 revision is carried as-is; + // the serving instance answers it through the protocol-version + // mismatch handoff rather than being silently re-routed. + const meta = { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }; + const outcome = classifyInboundRequest(post(modernToolsCall(meta))); + expect(outcome).toMatchObject({ kind: 'modern', classification: { era: 'legacy', revision: '2025-06-18' } }); + }); + + test('a claim with a malformed envelope is rejected, never silently treated as legacy', () => { + const meta = { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }; + const outcome = classifyInboundRequest(post(modernToolsCall(meta))); + expect(outcome).toMatchObject({ + kind: 'reject', + rung: 'envelope', + cell: 'envelope-invalid', + httpStatus: 400, + code: -32_602, + settled: true, + data: { envelope: { key: CLIENT_INFO_META_KEY, problem: 'missing' } } + }); + }); + + test('a claim with malformed client capabilities names the offending key', () => { + const meta = { ...ENVELOPE, [CLIENT_CAPABILITIES_META_KEY]: { sampling: 'yes' } }; + const outcome = classifyInboundRequest(post(modernToolsCall(meta))); + expect(outcome.kind).toBe('reject'); + if (outcome.kind !== 'reject') return; + expect(outcome.code).toBe(-32_602); + const data = outcome.data as { envelope: { key: string } }; + expect(data.envelope.key).toContain(CLIENT_CAPABILITIES_META_KEY); + }); +}); + +describe('header cross-checks (parameterized mismatch family)', () => { + test('a body claim disagreeing with the protocol-version header is a mismatch outcome', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: '2025-06-18' })); + expectMismatch(outcome, 'header-body-version-mismatch'); + }); + + test('a modern header on a claim-less body is a mismatch outcome, not an upgrade', () => { + const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: MODERN_REVISION })); + expectMismatch(outcome, 'modern-header-without-claim'); + }); + + test('initialize with a modern protocol-version header is a mismatch outcome', () => { + const outcome = classifyInboundRequest(post(initializeRequest(), { protocolVersion: MODERN_REVISION })); + expectMismatch(outcome, 'initialize-with-modern-header'); + }); + + test('an enveloped initialize whose claim disagrees with the protocol-version header is still a mismatch outcome', () => { + // The claim precedence never bypasses the cross-checks: an initialize + // carrying a valid modern claim is checked against the header exactly + // like any other enveloped request. + const body = { jsonrpc: '2.0', id: 7, method: 'initialize', params: { _meta: ENVELOPE } }; + const outcome = classifyInboundRequest(post(body, { protocolVersion: '2025-06-18' })); + expectMismatch(outcome, 'header-body-version-mismatch'); + }); + + test('an Mcp-Method header disagreeing with the body method is a mismatch outcome on modern requests', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: MODERN_REVISION, mcpMethod: 'tools/list' })); + expectMismatch(outcome, 'method-header-mismatch'); + }); + + test('a matching Mcp-Method header passes', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: MODERN_REVISION, mcpMethod: 'tools/call' })); + expect(outcome.kind).toBe('modern'); + }); + + test('the Mcp-Method header is never enforced on legacy requests', () => { + const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: '2025-06-18', mcpMethod: 'tools/call' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'no-claim' }); + }); +}); + +describe('notification routing (header determinative when the body carries no claim)', () => { + test('a modern protocol-version header routes a claim-less notification to modern serving', () => { + const outcome = classifyInboundRequest(post(notification(), { protocolVersion: MODERN_REVISION })); + expect(outcome).toMatchObject({ + kind: 'modern', + messageKind: 'notification', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + }); + + test('a header-stripped notification stays legacy traffic', () => { + const outcome = classifyInboundRequest(post(notification())); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'notification' }); + }); + + test('a legacy protocol-version header keeps the notification legacy', () => { + const outcome = classifyInboundRequest(post(notification(), { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'notification', requestedVersion: '2025-06-18' }); + }); + + test('the Mcp-Method header is validated on modern notifications', () => { + const outcome = classifyInboundRequest( + post(notification('notifications/progress'), { protocolVersion: MODERN_REVISION, mcpMethod: 'notifications/cancelled' }) + ); + expectMismatch(outcome, 'notification-method-header-mismatch'); + }); + + test('the Mcp-Method header is never enforced on legacy notifications', () => { + const outcome = classifyInboundRequest( + post(notification('notifications/progress'), { protocolVersion: '2025-06-18', mcpMethod: 'notifications/cancelled' }) + ); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'notification' }); + }); + + test('a notification body claim wins over the header and a disagreement is rejected', () => { + const meta = { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }; + const claimed = classifyInboundRequest(post(notification('notifications/progress', meta))); + expect(claimed).toMatchObject({ kind: 'modern', classification: { revision: MODERN_REVISION } }); + + const conflicting = classifyInboundRequest(post(notification('notifications/progress', meta), { protocolVersion: '2025-06-18' })); + expectMismatch(conflicting, 'notification-header-body-version-mismatch'); + }); + + test('a notification claim with a malformed value is rejected, naming the offending key', () => { + // Validated exactly like a request claim: invalid params naming the + // key — never silently losing to (or overriding) a disagreeing header. + const meta = { [PROTOCOL_VERSION_META_KEY]: 42 }; + const outcome = classifyInboundRequest(post(notification('notifications/progress', meta))); + expect(outcome).toMatchObject({ + kind: 'reject', + rung: 'envelope', + cell: 'notification-envelope-invalid', + httpStatus: 400, + code: -32_602, + settled: true + }); + if (outcome.kind !== 'reject') return; + const data = outcome.data as { envelope: { key: string } }; + expect(data.envelope.key).toBe(PROTOCOL_VERSION_META_KEY); + }); + + test('a notification claim with a malformed value is rejected the same way when a legacy header disagrees', () => { + const meta = { [PROTOCOL_VERSION_META_KEY]: 42 }; + const outcome = classifyInboundRequest(post(notification('notifications/progress', meta), { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'reject', rung: 'envelope', cell: 'notification-envelope-invalid', code: -32_602 }); + }); + + test('a notification with no claim at all keeps header-determinative routing (not envelope-validated)', () => { + // Only a present claim is validated; claim-less notifications keep the + // header-determinative routing above unchanged. + expect(classifyInboundRequest(post(notification(), { protocolVersion: MODERN_REVISION }))).toMatchObject({ kind: 'modern' }); + expect(classifyInboundRequest(post(notification()))).toMatchObject({ kind: 'legacy', reason: 'notification' }); + }); +}); + +describe('element-wise batch classification', () => { + test('an all-legacy array stays legacy traffic unchanged', () => { + const outcome = classifyInboundRequest(post([legacyToolsList(), notification()])); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'batch' }); + }); + + test('a single-element array is still an array', () => { + const outcome = classifyInboundRequest(post([legacyToolsList()])); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'batch' }); + }); + + test('an array containing a response element stays legacy traffic', () => { + const outcome = classifyInboundRequest(post([{ jsonrpc: '2.0', id: 9, result: {} }, legacyToolsList()])); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'batch' }); + }); + + test('an array containing a modern-claiming element is rejected', () => { + const outcome = classifyInboundRequest(post([legacyToolsList(), modernToolsCall()])); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'batch-with-modern-element', code: -32_600, httpStatus: 400, settled: true }); + }); + + test('an array containing an invalid element is rejected', () => { + const outcome = classifyInboundRequest(post([legacyToolsList(), { not: 'json-rpc' }])); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'batch-with-invalid-element', code: -32_600, httpStatus: 400 }); + }); + + test('an empty array is rejected', () => { + const outcome = classifyInboundRequest(post([])); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'empty-batch', code: -32_600 }); + }); +}); + +describe('responses and malformed bodies', () => { + test('a posted result response is legacy session traffic', () => { + const outcome = classifyInboundRequest(post({ jsonrpc: '2.0', id: 3, result: {} })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'response' }); + }); + + test('a posted error response is legacy session traffic', () => { + const outcome = classifyInboundRequest(post({ jsonrpc: '2.0', id: 3, error: { code: -32_000, message: 'oops' } })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'response' }); + }); + + test('a body that is not a JSON-RPC message is rejected', () => { + const outcome = classifyInboundRequest(post({ hello: 'world' })); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'invalid-json-rpc-body', code: -32_600, httpStatus: 400 }); + }); + + test('a missing body is rejected', () => { + const outcome = classifyInboundRequest({ httpMethod: 'POST' }); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'invalid-json-rpc-body', code: -32_600 }); + }); +}); + +describe('modern-only (strict) rejection mapping', () => { + const SUPPORTED = [MODERN_REVISION]; + const legacyRoute = (body: unknown, headers: { protocolVersion?: string } = {}): InboundLegacyRoute => { + const outcome = classifyInboundRequest(post(body, headers)); + expect(outcome.kind).toBe('legacy'); + return outcome as InboundLegacyRoute; + }; + + test('an envelope-less request that named no version omits `requested` rather than fabricating one', () => { + const rejectionOutcome = modernOnlyStrictRejection(legacyRoute(legacyToolsList()), SUPPORTED); + expect(rejectionOutcome).toMatchObject({ + cell: 'modern-only-missing-envelope', + httpStatus: 400, + code: -32_004, + settled: true, + data: { supported: SUPPORTED } + }); + expect((rejectionOutcome?.data as { requested?: unknown })?.requested).toBeUndefined(); + expect(Object.keys(rejectionOutcome?.data as Record)).not.toContain('requested'); + expect(rejectionOutcome?.message).toContain('Unsupported protocol version'); + }); + + test('an envelope-less initialize names the version it requested', () => { + const rejectionOutcome = modernOnlyStrictRejection(legacyRoute(initializeRequest('2025-06-18')), SUPPORTED); + expect(rejectionOutcome).toMatchObject({ code: -32_004, data: { supported: SUPPORTED, requested: '2025-06-18' } }); + }); + + test('an envelope-less request echoes the protocol-version header it sent', () => { + const rejectionOutcome = modernOnlyStrictRejection(legacyRoute(legacyToolsList(), { protocolVersion: '2025-03-26' }), SUPPORTED); + expect(rejectionOutcome).toMatchObject({ code: -32_004, data: { requested: '2025-03-26' } }); + }); + + test('batch and response POSTs are invalid requests on a modern-only endpoint', () => { + expect(modernOnlyStrictRejection(legacyRoute([legacyToolsList()]), SUPPORTED)).toMatchObject({ code: -32_600, httpStatus: 400 }); + expect(modernOnlyStrictRejection(legacyRoute({ jsonrpc: '2.0', id: 1, result: {} }), SUPPORTED)).toMatchObject({ + code: -32_600, + httpStatus: 400 + }); + }); + + test('non-POST methods are not allowed on a modern-only endpoint', () => { + const route = classifyInboundRequest({ httpMethod: 'GET' }) as InboundLegacyRoute; + expect(modernOnlyStrictRejection(route, SUPPORTED)).toMatchObject({ + httpStatus: 405, + code: -32_000, + message: 'Method not allowed.' + }); + }); + + test('legacy-classified notifications are accepted-and-dropped (no rejection body)', () => { + const route = classifyInboundRequest(post(notification())) as InboundLegacyRoute; + expect(modernOnlyStrictRejection(route, SUPPORTED)).toBeUndefined(); + }); +}); diff --git a/packages/core/test/shared/inboundLadderCellSheet.test.ts b/packages/core/test/shared/inboundLadderCellSheet.test.ts new file mode 100644 index 0000000000..d1e661543b --- /dev/null +++ b/packages/core/test/shared/inboundLadderCellSheet.test.ts @@ -0,0 +1,460 @@ +/** + * The inbound validation-ladder cell sheet. + * + * Each row names one ladder cell, whether its outcome is pinned or + * parameterized, the conformance scenarios that exercise it (where one + * exists), and the expected outcome. Pinned rows assert exact codes and HTTP + * statuses; parameterized rows assert the outcome class and that the emitted + * code is the documented provisional value drawn from the candidate set — + * those cells are re-derived when a published conformance release settles the + * disputed assignments (see the note in + * `test/conformance/expected-failures.yaml`). + * + * Cells evaluated at protocol dispatch (the era registry gate, per-method + * params, capability assertion) are listed for ordering and status mapping + * only; their end-to-end HTTP assertions live with the per-request server + * transport tests in the server package. + */ +import { describe, expect, test } from 'vitest'; + +import type { InboundHttpRequest, InboundLadderRejection } from '../../src/shared/inboundClassification.js'; +import { + classifyInboundRequest, + httpStatusForErrorCode, + INBOUND_VALIDATION_LADDER, + LADDER_ERROR_HTTP_STATUS, + modernOnlyStrictRejection, + PROVISIONAL_CROSS_CHECK_MISMATCH_CODE +} from '../../src/shared/inboundClassification.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; + +const MODERN_REVISION = '2026-07-28'; +const MISMATCH_CODE_CANDIDATES = [-32_001, -32_602, -32_004]; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'cell-sheet-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const enveloped = (method: string, params: Record = {}) => ({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: ENVELOPE } +}); +const bare = (method: string, params: Record = {}) => ({ jsonrpc: '2.0', id: 1, method, params }); +const post = (body: unknown, headers: { protocolVersion?: string; mcpMethod?: string } = {}): InboundHttpRequest => ({ + httpMethod: 'POST', + body, + ...(headers.protocolVersion !== undefined && { protocolVersionHeader: headers.protocolVersion }), + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }) +}); + +interface SheetRow { + /** Stable cell identifier (matches `InboundLadderRejection.cell` for rejection cells). */ + cell: string; + /** Pinned cells assert exact outcomes; parameterized cells assert the provisional outcome + candidate-set membership. */ + status: 'pinned' | 'parameterized'; + /** Conformance scenarios exercising the cell, where one exists in the published referee. */ + conformance: readonly string[]; + /** The classifier input. */ + input: InboundHttpRequest; + /** Strict (modern-only) mapping applies: the legacy route is mapped through `modernOnlyStrictRejection`. */ + strict?: boolean; + /** The expected outcome for routing cells. */ + route?: 'legacy' | 'modern'; + /** The expected rejection (exact for pinned cells; for parameterized cells `code` is the provisional value). */ + reject?: Partial; + /** Why the cell behaves the way it does. */ + rationale: string; +} + +const SHEET: readonly SheetRow[] = [ + /* --- Routing cells (pinned) --------------------------------------------------- */ + { + cell: 'modern-enveloped-request', + status: 'pinned', + conformance: ['server-stateless'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: MODERN_REVISION }), + route: 'modern', + rationale: 'A request carrying the per-request envelope claim is modern-era traffic.' + }, + { + cell: 'modern-enveloped-request-header-stripped', + status: 'pinned', + conformance: ['server-stateless'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} })), + route: 'modern', + rationale: 'Body-primary classification: a proxy stripping the protocol-version header must not change the era.' + }, + { + cell: 'legacy-claimless-request', + status: 'pinned', + conformance: [], + input: post(bare('tools/list'), { protocolVersion: '2025-06-18' }), + route: 'legacy', + rationale: 'A request without an envelope claim is legacy traffic and is never classified.' + }, + { + cell: 'legacy-initialize', + status: 'pinned', + conformance: [], + input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), + route: 'legacy', + rationale: 'initialize is the legacy handshake by definition; the modern era has no initialize.' + }, + { + cell: 'modern-enveloped-initialize', + status: 'pinned', + conformance: ['server-stateless'], + input: post(enveloped('initialize'), { protocolVersion: MODERN_REVISION, mcpMethod: 'initialize' }), + route: 'modern', + rationale: + 'A valid modern envelope claim wins over the initialize ⇒ legacy-handshake rule: the request is served on the modern path, ' + + 'where the modern registry answers initialize as method-not-found (-32601, HTTP 404 via the ladder status table) like every ' + + 'other method the revision does not define.' + }, + { + cell: 'legacy-method-routed-get', + status: 'pinned', + conformance: [], + input: { httpMethod: 'GET' }, + route: 'legacy', + rationale: 'GET/DELETE are body-less 2025-era session operations; the modern era is POST-only.' + }, + { + cell: 'legacy-notification-stripped-header', + status: 'pinned', + conformance: [], + input: post({ jsonrpc: '2.0', method: 'notifications/initialized' }), + route: 'legacy', + rationale: + 'A notification without a body claim or a modern header stays legacy traffic (dual mode routes it; strict mode accepts and drops it).' + }, + { + cell: 'modern-notification-by-header', + status: 'pinned', + conformance: ['http-header-validation'], + input: post({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, { protocolVersion: MODERN_REVISION }), + route: 'modern', + rationale: 'Notifications carry no body claim, so the modern protocol-version header is determinative for them.' + }, + { + cell: 'legacy-batch', + status: 'pinned', + conformance: [], + input: post([bare('tools/list')]), + route: 'legacy', + rationale: 'All-legacy arrays go to legacy serving unchanged; a single-element array is still an array.' + }, + { + cell: 'legacy-response-post', + status: 'pinned', + conformance: [], + input: post({ jsonrpc: '2.0', id: 5, result: {} }), + route: 'legacy', + rationale: 'Posted responses are 2025-era session traffic (replies to server-initiated requests).' + }, + + /* --- Edge rejection cells (pinned) -------------------------------------------- */ + { + cell: 'envelope-invalid', + status: 'pinned', + conformance: ['server-stateless'], + input: post({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }), + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, + rationale: 'A present claim with a malformed envelope is invalid params naming the key — never a silent legacy fallthrough.' + }, + { + cell: 'batch-with-modern-element', + status: 'pinned', + conformance: [], + input: post([bare('tools/list'), enveloped('tools/call', { name: 'echo', arguments: {} })]), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'Element-wise batch rule: one modern element makes the array unservable on either path.' + }, + { + cell: 'batch-with-invalid-element', + status: 'pinned', + conformance: [], + input: post([bare('tools/list'), { nonsense: true }]), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'Element-wise batch rule: invalid elements are rejected rather than partially served.' + }, + { + cell: 'invalid-json-rpc-body', + status: 'pinned', + conformance: [], + input: post({ hello: 'world' }), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: + 'A POST body that is not a JSON-RPC message is an invalid request (-32600, the JSON-RPC-correct code). Deliberate ' + + 'divergence from the deployed 2025-era transport, which answers -32700 for the same parsed body; enumerated and ' + + 'exercised on both legs in the era-parity suite (server package).' + }, + { + cell: 'empty-batch', + status: 'pinned', + conformance: [], + input: post([]), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: + 'An empty JSON-RPC batch is an invalid request at the modern edge. Deliberate divergence from the deployed 2025-era ' + + 'transport, which accepts an empty array as containing only notifications (202, no body); enumerated and exercised on ' + + 'both legs in the era-parity suite (server package).' + }, + { + cell: 'notification-envelope-invalid', + status: 'pinned', + conformance: [], + input: post({ jsonrpc: '2.0', method: 'notifications/progress', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } } }), + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, + rationale: + 'A notification claim with a malformed protocol-version value is invalid params naming the key — exactly like the ' + + 'request path, never a silent win against (or loss to) a disagreeing header.' + }, + + /* --- Modern-only (strict) cells (pinned) --------------------------------------- */ + { + cell: 'modern-only-missing-envelope', + status: 'pinned', + conformance: ['server-stateless'], + input: post(bare('tools/list')), + strict: true, + reject: { rung: 'era-classification', httpStatus: 400, code: -32_004, settled: true }, + rationale: + 'A modern-only endpoint answers envelope-less requests with the unsupported-protocol-version error and its supported list. ' + + 'This cell shares its numeric code with the disputed mismatch family but is itself settled.' + }, + { + cell: 'modern-only-missing-envelope-initialize', + status: 'pinned', + conformance: ['server-stateless'], + input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), + strict: true, + reject: { + rung: 'era-classification', + httpStatus: 400, + code: -32_004, + settled: true, + data: { supported: [MODERN_REVISION], requested: '2025-06-18' } + }, + rationale: + 'An envelope-less initialize on a modern-only endpoint is answered with the version error naming both sides — the ' + + 'unsupported-protocol-version rejection with the supported list stays reserved for envelope-less requests.' + }, + { + cell: 'modern-only-method-not-allowed', + status: 'pinned', + conformance: [], + input: { httpMethod: 'DELETE' }, + strict: true, + reject: { rung: 'http-method', httpStatus: 405, code: -32_000, settled: true }, + rationale: 'Without legacy serving configured there is nothing to route GET/DELETE to.' + }, + { + cell: 'modern-only-batch-not-supported', + status: 'pinned', + conformance: [], + input: post([bare('tools/list')]), + strict: true, + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'Batches are not part of the modern wire shape.' + }, + { + cell: 'modern-only-response-post', + status: 'pinned', + conformance: [], + input: post({ jsonrpc: '2.0', id: 5, result: {} }), + strict: true, + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'There is no server-to-client request channel on the modern era, so posted responses are invalid requests.' + }, + + /* --- Parameterized cells (disputed error-code assignments) --------------------- */ + { + cell: 'header-body-version-mismatch', + status: 'parameterized', + conformance: ['http-header-validation', 'http-custom-header-server-validation'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: '2025-06-18' }), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: 'Header/body protocol-version disagreement; the exact code is still under discussion upstream.' + }, + { + cell: 'modern-header-without-claim', + status: 'parameterized', + conformance: ['http-header-validation'], + input: post(bare('tools/list'), { protocolVersion: MODERN_REVISION }), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: 'A modern header on a claim-less body is a disagreement, not an upgrade; code pending upstream settlement.' + }, + { + cell: 'initialize-with-modern-header', + status: 'parameterized', + conformance: ['http-header-validation'], + input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } }), { + protocolVersion: MODERN_REVISION + }), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: 'An envelope-less initialize classifies legacy; a modern header on it is the same disagreement family.' + }, + { + cell: 'method-header-mismatch', + status: 'parameterized', + conformance: ['http-custom-header-server-validation'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { + protocolVersion: MODERN_REVISION, + mcpMethod: 'tools/list' + }), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: 'The Mcp-Method header must describe the body it accompanies; the rejection code is pending upstream settlement.' + }, + { + cell: 'notification-header-body-version-mismatch', + status: 'parameterized', + conformance: [], + input: post( + { jsonrpc: '2.0', method: 'notifications/progress', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }, + { protocolVersion: '2025-06-18' } + ), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: + 'A notification body claim disagreeing with the protocol-version header is the same disagreement family as the request ' + + 'cells above; the exact code is still under discussion upstream.' + }, + { + cell: 'notification-method-header-mismatch', + status: 'parameterized', + conformance: [], + input: post( + { jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: 1, progress: 1 } }, + { protocolVersion: MODERN_REVISION, mcpMethod: 'notifications/cancelled' } + ), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: + 'The Mcp-Method header must describe the notification body it accompanies (validated only when the notification ' + + 'classifies modern); the rejection code is pending upstream settlement.' + }, + { + cell: 'multi-fault-mismatched-claim-and-malformed-envelope', + status: 'parameterized', + conformance: ['server-stateless', 'http-header-validation'], + // The claim names a different version than the header AND the envelope + // is missing required keys: today the envelope rung answers (the + // mismatch is only checked on a valid envelope), so the emitted code is + // -32602 — but the precedence between the era-classification and + // envelope rungs for multi-fault requests is part of the disputed set. + input: post( + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }, + { + protocolVersion: '2025-06-18' + } + ), + reject: { httpStatus: 400 }, + rationale: + 'Multi-fault precedence between the version error and invalid params is not settled upstream; asserted as candidate-set membership only.' + } +]; + +describe('inbound validation-ladder cell sheet', () => { + const SUPPORTED = [MODERN_REVISION]; + + test.each(SHEET)('$cell', row => { + let outcome = classifyInboundRequest(row.input); + if (row.strict) { + expect(outcome.kind).toBe('legacy'); + if (outcome.kind !== 'legacy') return; + const mapped = modernOnlyStrictRejection(outcome, SUPPORTED); + expect(mapped).toBeDefined(); + outcome = mapped!; + } + + if (row.route !== undefined) { + expect(outcome.kind).toBe(row.route); + if (row.route === 'legacy') { + // Legacy routes never carry a classification (hand-wired and + // legacy traffic is never classified). + expect('classification' in outcome).toBe(false); + } + return; + } + + expect(outcome.kind).toBe('reject'); + if (outcome.kind !== 'reject') return; + + if (row.status === 'pinned') { + expect(outcome).toMatchObject(row.reject ?? {}); + } else { + // Parameterized: outcome class and provisional code only — the + // exact assignment is re-derived from a future conformance pin. + if (row.reject?.rung !== undefined) expect(outcome.rung).toBe(row.reject.rung); + if (row.reject?.httpStatus !== undefined) expect(outcome.httpStatus).toBe(row.reject.httpStatus); + expect(MISMATCH_CODE_CANDIDATES).toContain(outcome.code); + if (row.reject?.settled !== undefined) { + expect(outcome.settled).toBe(row.reject.settled); + expect(outcome.code).toBe(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE); + } + } + }); + + test('every cell id is unique and every parameterized cell is marked unsettled or candidate-bound', () => { + const ids = SHEET.map(row => row.cell); + expect(new Set(ids).size).toBe(ids.length); + for (const row of SHEET.filter(candidate => candidate.status === 'parameterized')) { + expect(row.reject).toBeDefined(); + } + }); +}); + +describe('the validation ladder as data', () => { + test('rungs are uniquely named and strictly ordered', () => { + const orders = INBOUND_VALIDATION_LADDER.map(rung => rung.order); + expect(orders.toSorted((a, b) => a - b)).toEqual(orders); + expect(new Set(orders).size).toBe(orders.length); + expect(new Set(INBOUND_VALIDATION_LADDER.map(rung => rung.rung)).size).toBe(INBOUND_VALIDATION_LADDER.length); + }); + + test('the edge rungs precede the dispatch rungs', () => { + const lastEdge = Math.max(...INBOUND_VALIDATION_LADDER.filter(rung => rung.evaluatedAt === 'edge').map(rung => rung.order)); + const firstDispatch = Math.min( + ...INBOUND_VALIDATION_LADDER.filter(rung => rung.evaluatedAt === 'dispatch').map(rung => rung.order) + ); + expect(lastEdge).toBeLessThan(firstDispatch); + }); + + test('method existence outranks parameter validity in the rung order', () => { + const methodRegistry = INBOUND_VALIDATION_LADDER.find(rung => rung.rung === 'method-registry'); + const requestParams = INBOUND_VALIDATION_LADDER.find(rung => rung.rung === 'request-params'); + expect(methodRegistry!.order).toBeLessThan(requestParams!.order); + }); +}); + +describe('HTTP status mapping for ladder-originated errors (origin-keyed)', () => { + test('the table maps exactly the ladder-originated codes', () => { + expect(LADDER_ERROR_HTTP_STATUS).toEqual({ + [-32_601]: 404, + [-32_004]: 400, + [-32_003]: 400, + [-32_001]: 400 + }); + }); + + test('the table never maps invalid params: the classifier envelope short-circuit is the only -32602 -> 400 source', () => { + expect(Object.keys(LADDER_ERROR_HTTP_STATUS)).not.toContain(String(-32_602)); + expect(httpStatusForErrorCode(-32_602, 'in-band')).toBe(200); + }); + + test('handler-originated errors stay in-band on HTTP 200, whatever their code', () => { + for (const code of [-32_603, -32_602, -32_601, -32_004, -32_002, -32_000, 1234]) { + expect(httpStatusForErrorCode(code, 'in-band')).toBe(200); + } + }); + + test('ladder-originated codes map to their HTTP statuses', () => { + expect(httpStatusForErrorCode(-32_601, 'ladder')).toBe(404); + expect(httpStatusForErrorCode(-32_004, 'ladder')).toBe(400); + expect(httpStatusForErrorCode(-32_003, 'ladder')).toBe(400); + expect(httpStatusForErrorCode(-32_001, 'ladder')).toBe(400); + }); +}); diff --git a/packages/server/src/server/invoke.ts b/packages/server/src/server/invoke.ts new file mode 100644 index 0000000000..f6c9c11359 --- /dev/null +++ b/packages/server/src/server/invoke.ts @@ -0,0 +1,68 @@ +/** + * The internal per-request invoke seam for modern-era HTTP serving. + * + * One classified inbound message is served by composing existing pieces, with + * no changes to the protocol dispatch layer: + * + * server instance (from the consumer's factory) + * → `connect(per-request transport)` + * → inject the classified message through the transport's message callback + * → capture the value (a single JSON body or an SSE stream) via the + * transport's send path. + * + * The seam is value-returning and independently testable: it resolves with the + * HTTP `Response` for the exchange. Marking factory instances as modern-era + * (and installing modern-only handlers) is the calling entry's responsibility + * and happens before this seam runs; the seam itself never writes era state. + */ +import type { AuthInfo, JSONRPCNotification, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; + +import type { McpServer } from './mcp.js'; +import type { PerRequestResponseMode } from './perRequestTransport.js'; +import { PerRequestHTTPServerTransport } from './perRequestTransport.js'; +import type { Server } from './server.js'; + +/** Per-exchange context for {@linkcode invoke}. */ +export interface InvokeContext { + /** The edge classification of the message (computed once, at the entry boundary). */ + classification: MessageClassification; + /** The original HTTP request, when serving HTTP traffic. */ + request?: globalThis.Request; + /** + * Validated authentication information supplied by the caller. Strictly + * pass-through — never derived from request headers by this seam. + */ + authInfo?: AuthInfo; + /** Response shaping for the exchange; defaults to `auto` (lazy SSE upgrade). */ + responseMode?: PerRequestResponseMode; +} + +/** + * Serves one classified inbound message on the given server instance and + * returns the HTTP response for the exchange. + * + * The instance is connected to a fresh single-exchange transport, the message + * is injected through the normal transport message path, and whatever the + * dispatch layer produces (the handler result, a protocol-level rejection, or + * streamed related messages followed by the result) is captured as the + * returned `Response`. For request exchanges, teardown rides the transport's + * close chain once the terminal response has been delivered; notification + * exchanges resolve with the 202 response immediately and do NOT run the + * close chain — the transport stays connected until the caller closes it or + * drops the per-request instance, which is the caller's choice either way. + */ +export async function invoke( + server: Server | McpServer, + message: JSONRPCRequest | JSONRPCNotification, + ctx: InvokeContext +): Promise { + const transport = new PerRequestHTTPServerTransport({ + classification: ctx.classification, + ...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode }) + }); + await server.connect(transport); + return transport.handleMessage(message, { + ...(ctx.request !== undefined && { request: ctx.request }), + ...(ctx.authInfo !== undefined && { authInfo: ctx.authInfo }) + }); +} diff --git a/packages/server/src/server/perRequestTransport.ts b/packages/server/src/server/perRequestTransport.ts new file mode 100644 index 0000000000..74eaaca51a --- /dev/null +++ b/packages/server/src/server/perRequestTransport.ts @@ -0,0 +1,404 @@ +/** + * A single-exchange, per-request HTTP server transport for modern-era + * (protocol revision 2026-07-28) serving. + * + * One transport instance serves exactly one already-classified inbound + * JSON-RPC message and produces exactly one HTTP `Response`: + * + * - a `202` with no body for notifications, + * - a single JSON body for requests whose handler produces no streamed + * output, or + * - a lazily-opened SSE stream when the handler emits related messages + * (notifications or server-to-client requests) before its result — the + * stream carries those messages and finally the terminal result, then + * closes. + * + * The transport is constructed already-classified: the entry parses and + * classifies the request body exactly once and hands the classification in via + * the constructor; the transport attaches it (together with the original + * request and any caller-provided auth info) to every message it delivers, and + * the protocol layer validates it against the serving instance's negotiated + * era. `authInfo` is strictly pass-through — it is never derived from the + * inbound request's headers here. + * + * Deliberately NOT carried over from the session-oriented streamable HTTP + * transport: session ids and session headers, resumability (event ids, + * priming events, `Last-Event-ID` replay, retry hints), the standalone GET + * stream, and request-header validation (which belongs to middleware). The + * exchange is single-use; serving another request requires a new transport + * (and, in the per-request serving model, a fresh server instance). + */ +import type { + AuthInfo, + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + MessageClassification, + MessageExtraInfo, + RequestId, + Transport, + TransportSendOptions +} from '@modelcontextprotocol/core'; +import { + isJSONRPCErrorResponse, + isJSONRPCRequest, + isJSONRPCResultResponse, + LADDER_ERROR_HTTP_STATUS, + SdkError, + SdkErrorCode +} from '@modelcontextprotocol/core'; + +/** + * How the transport shapes its HTTP response for a request: + * + * - `auto` (default): answer with a single JSON body unless the handler emits + * a related message before its result, in which case the response upgrades + * to an SSE stream. + * - `sse`: always answer handler output over an SSE stream. The stream opens + * once the request has passed the pre-dispatch validation gates, so ladder + * rejections keep their mapped HTTP status instead of being framed onto a + * 200 stream. + * - `json`: never stream; related messages other than the terminal response + * are dropped. + */ +export type PerRequestResponseMode = 'auto' | 'sse' | 'json'; + +/** Constructor options for {@linkcode PerRequestHTTPServerTransport}. */ +export interface PerRequestHTTPServerTransportOptions { + /** The edge classification of the message this transport will serve. */ + classification: MessageClassification; + /** Response shaping for the exchange; defaults to `auto`. */ + responseMode?: PerRequestResponseMode; +} + +/** Per-exchange context handed to {@linkcode PerRequestHTTPServerTransport.handleMessage}. */ +export interface PerRequestMessageExtra { + /** + * The original HTTP request. Used for handler context and, when the + * runtime provides an abort signal on it, to cancel the exchange when the + * client disconnects. + */ + request?: globalThis.Request; + /** + * Validated authentication information supplied by the caller. Strictly + * pass-through: the transport never populates this from request headers. + */ + authInfo?: AuthInfo; +} + +interface DeferredResponse { + promise: Promise; + resolve: (response: Response) => void; + reject: (error: Error) => void; + settled: boolean; +} + +interface SseSink { + controller: ReadableStreamDefaultController; + encoder: InstanceType; + closed: boolean; +} + +/** + * The per-request micro-transport: a real, connected `Transport` whose whole + * lifetime is one HTTP exchange. See the module documentation for the + * response shapes it produces. + */ +export class PerRequestHTTPServerTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: T, extra?: MessageExtraInfo) => void; + + private readonly _classification: MessageClassification; + private readonly _responseMode: PerRequestResponseMode; + + private _started = false; + private _used = false; + private _closed = false; + private _terminalDelivered = false; + /** + * `true` only while the inbound message is being delivered synchronously + * to the connected protocol layer. The pre-handler gates (the era + * registry gate, the edge→instance handoff check, the missing-handler + * rejection) answer inside this window; request handlers always run + * after it (the protocol layer defers them to a microtask). An error + * sent inside the window is therefore ladder-originated, and an error + * sent after it is handler-produced. + */ + private _dispatchWindowOpen = false; + private _requestId?: RequestId; + private _deferredResponse?: DeferredResponse; + private _sse?: SseSink; + private _abortCleanup?: () => void; + + constructor(options: PerRequestHTTPServerTransportOptions) { + this._classification = options.classification; + this._responseMode = options.responseMode ?? 'auto'; + } + + async start(): Promise { + if (this._started) { + throw new Error('PerRequestHTTPServerTransport is already started'); + } + this._started = true; + } + + /** + * Serves the single exchange: delivers the classified message to the + * connected server instance and resolves with the HTTP response. + * + * Throws when called a second time (the transport is strictly + * single-use), or before a server has been connected to the transport. + * The returned promise rejects with a connection-closed error when the + * transport is closed before a response was produced (for example because + * the client disconnected). + */ + async handleMessage(message: JSONRPCRequest | JSONRPCNotification, extra?: PerRequestMessageExtra): Promise { + if (this._used) { + throw new Error('PerRequestHTTPServerTransport serves exactly one exchange; construct a new transport per request'); + } + if (!this._started || this.onmessage === undefined) { + throw new Error('PerRequestHTTPServerTransport is not connected: connect a server to this transport before handling a message'); + } + if (this._closed) { + throw new Error('PerRequestHTTPServerTransport is closed'); + } + this._used = true; + + const signal = extra?.request?.signal; + if (signal?.aborted) { + await this.close(); + throw new SdkError(SdkErrorCode.ConnectionClosed, 'The request was aborted before it could be handled'); + } + + // authInfo is strictly pass-through from the caller; it is never + // derived from the inbound request's headers. + const messageExtra: MessageExtraInfo = { + classification: this._classification, + ...(extra?.request !== undefined && { request: extra.request }), + ...(extra?.authInfo !== undefined && { authInfo: extra.authInfo }) + }; + + if (isJSONRPCRequest(message)) { + this._requestId = message.id; + + let resolve!: (response: Response) => void; + let reject!: (error: Error) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + this._deferredResponse = { promise, resolve, reject, settled: false }; + + if (signal !== undefined) { + const onAbort = () => void this.close(); + signal.addEventListener('abort', onAbort, { once: true }); + this._abortCleanup = () => signal.removeEventListener('abort', onAbort); + } + + this._dispatchWindowOpen = true; + try { + this.onmessage(message, messageExtra); + } finally { + this._dispatchWindowOpen = false; + } + + if (this._responseMode === 'sse' && !this._closed && !this._deferredResponse.settled) { + // Forced-SSE exchanges open their stream as soon as the + // request has passed the pre-dispatch gates: a ladder + // rejection settles inside the dispatch window with its + // mapped HTTP status, while handler output — including + // comment frames written before the first message — streams + // as before. + this.upgradeToSse(); + } + return promise; + } + + // Notifications never get a JSON-RPC response: deliver the message and + // acknowledge the POST with 202 and no body. + this.onmessage(message, messageExtra); + return new Response(null, { status: 202 }); + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + if (this._closed) { + // The exchange is over; late writes are dropped. + return; + } + + const isResponse = isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message); + const relatedId = isResponse ? (message as { id: RequestId }).id : options?.relatedRequestId; + + if (this._requestId === undefined || relatedId === undefined || relatedId !== this._requestId) { + if (isResponse) { + this.onerror?.(new Error(`Received a response for an unknown request id: ${String((message as { id?: unknown }).id)}`)); + } + // Messages unrelated to the single in-flight request have nowhere + // to go on a per-request exchange (there is no session-wide + // stream); they are dropped. + return; + } + + if (isResponse) { + if (this._terminalDelivered) { + return; + } + this._terminalDelivered = true; + + // The HTTP status is keyed on the error's origin, not on its bare + // code: only errors produced inside the dispatch window — the + // validation ladder, the era registry gate and handoff check, a + // missing handler — are answered with the mapped HTTP status from + // the ladder table. Handler-produced errors, whatever their code, + // stay in-band on HTTP 200. Ladder rejections keep that mapped + // status in every response mode (the SSE upgrade is deferred to + // the first actual send), so a forced-`sse` exchange still + // answers pre-dispatch rejections as plain HTTP errors. + const ladderStatus = + this._dispatchWindowOpen && isJSONRPCErrorResponse(message) + ? LADDER_ERROR_HTTP_STATUS[(message as JSONRPCErrorResponse).error.code] + : undefined; + if (ladderStatus !== undefined && this._sse === undefined) { + this.settleResponse(Response.json(message, { status: ladderStatus, headers: { 'Content-Type': 'application/json' } })); + queueMicrotask(() => void this.close()); + return; + } + + if (this._sse !== undefined || this._responseMode === 'sse') { + // Finalize the stream: serialize the terminal result onto it + // after everything already enqueued, then close. + if (this._sse === undefined) { + this.upgradeToSse(); + } + this.writeMessageFrame(message); + this.finalizeStream(); + return; + } + + // Single JSON body. + this.settleResponse(Response.json(message, { status: 200, headers: { 'Content-Type': 'application/json' } })); + queueMicrotask(() => void this.close()); + return; + } + + // A message related to the in-flight request that is not its terminal + // response: a mid-call notification or a server-to-client request + // emitted by the handler. + if (this._responseMode === 'json') { + // JSON responses cannot carry mid-call messages; they are dropped. + return; + } + if (this._sse === undefined) { + this.upgradeToSse(); + } + this.writeMessageFrame(message); + } + + /** + * Writes an SSE comment frame (a keep-alive heartbeat). Dropped when the + * exchange is not currently streaming. + */ + writeCommentFrame(comment: string): void { + if (this._closed || this._sse === undefined || this._sse.closed) { + return; + } + const frame = comment + .split('\n') + .map(line => `: ${line}`) + .join('\n'); + this.writeFrame(`${frame}\n\n`); + } + + async close(): Promise { + if (this._closed) { + return; + } + this._closed = true; + + this._abortCleanup?.(); + this._abortCleanup = undefined; + + if (this._sse !== undefined && !this._sse.closed) { + this._sse.closed = true; + try { + this._sse.controller.close(); + } catch { + // The stream was already closed or cancelled by the consumer. + } + } + + if (this._deferredResponse !== undefined && !this._deferredResponse.settled) { + this._deferredResponse.settled = true; + this._deferredResponse.reject(new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed before a response was produced')); + } + + this.onclose?.(); + } + + private settleResponse(response: Response): void { + if (this._deferredResponse === undefined || this._deferredResponse.settled) { + return; + } + this._deferredResponse.settled = true; + this._deferredResponse.resolve(response); + } + + private upgradeToSse(): void { + let controller!: ReadableStreamDefaultController; + const readable = new ReadableStream({ + start: streamController => { + controller = streamController; + }, + cancel: () => { + // The client went away mid-stream: tear the exchange down, + // which aborts the in-flight handler through the connected + // server's close chain. + void this.close(); + } + }); + this._sse = { controller, encoder: new TextEncoder(), closed: false }; + + this.settleResponse( + new Response(readable, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + // Disable proxy buffering so streamed messages are + // delivered as they are written. + 'X-Accel-Buffering': 'no' + } + }) + ); + } + + private finalizeStream(): void { + if (this._sse !== undefined && !this._sse.closed) { + this._sse.closed = true; + try { + this._sse.controller.close(); + } catch { + // The stream was already cancelled by the consumer. + } + } + queueMicrotask(() => void this.close()); + } + + private writeMessageFrame(message: JSONRPCMessage): void { + this.writeFrame(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + } + + private writeFrame(frame: string): void { + if (this._sse === undefined || this._sse.closed) { + return; + } + try { + this._sse.controller.enqueue(this._sse.encoder.encode(frame)); + } catch (error) { + this.onerror?.(new Error(`Failed to write to the response stream: ${error}`)); + } + } +} diff --git a/packages/server/test/server/eraParityErrorShapes.test.ts b/packages/server/test/server/eraParityErrorShapes.test.ts new file mode 100644 index 0000000000..7e80616a0b --- /dev/null +++ b/packages/server/test/server/eraParityErrorShapes.test.ts @@ -0,0 +1,246 @@ +/** + * Era-parity error shapes: the same malformed input produces the same + * JSON-RPC error shape on the 2025-era (session-oriented streamable HTTP + * transport) and on the modern per-request path — modulo an explicitly + * enumerated table of era-mandated differences. Anything outside that table + * is a parity regression. + */ +import type { CallToolResult, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; +import { + classifyInboundRequest, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + ProtocolError, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; +import { Server } from '../../src/server/server.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'parity-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +/** + * Era-mandated differences between the two serving paths for the inputs + * exercised below. Everything else must be identical. + * + * - HTTP status: pre-handler rejections are status-mapped on the modern + * per-request path (e.g. method-not-found answers HTTP 404), while the + * 2025-era transport always carries dispatch errors in-band on HTTP 200. + * Asserted literally on both legs by the unknown-method test below. + * - The modern era requires the per-request `_meta` envelope on every + * request; the inputs below carry it on the modern leg only, where it is + * wire-level bookkeeping that never reaches handlers. + * - The malformed-body divergences enumerated in {@link KNOWN_EDGE_DIVERGENCES}, + * asserted literally on both legs by the divergence-table test below. + */ + +/** + * Known, deliberate divergences between what the deployed 2025-era streamable + * HTTP transport answers for a malformed POST body and what the modern edge + * (the inbound classifier) answers for the same body. + * + * These are hand-written literals — NOT derived from the observed behavior of + * either leg — so a behavior change on EITHER side fails the assertions below + * and forces this enumeration (and the matching cell-sheet rationales in the + * core package) to be revisited. + */ +const KNOWN_EDGE_DIVERGENCES: ReadonlyArray<{ + divergence: string; + /** The parsed POST body both legs receive. */ + body: unknown; + /** What the deployed 2025-era transport answers today. */ + legacy: { httpStatus: number; code?: number }; + /** What the modern edge (the inbound classifier) answers. */ + modernEdge: { httpStatus: number; code: number }; + rationale: string; +}> = [ + { + divergence: 'parsed-but-not-json-rpc-single-object', + body: { hello: 'world' }, + legacy: { httpStatus: 400, code: -32_700 }, + modernEdge: { httpStatus: 400, code: -32_600 }, + rationale: + 'The deployed transport answers a parse error (-32700) for a parsed body that is not a JSON-RPC message; the modern ' + + 'edge answers the JSON-RPC-correct invalid request (-32600).' + }, + { + divergence: 'empty-batch', + body: [], + legacy: { httpStatus: 202 }, + modernEdge: { httpStatus: 400, code: -32_600 }, + rationale: + 'The deployed transport accepts an empty batch as containing only notifications (202, no body); the modern edge ' + + 'rejects it as an invalid request.' + } +]; + +interface LegError { + status: number; + error: { code: number; message: string; data?: unknown }; +} + +function buildServer(): Server { + const server = new Server({ name: 'parity', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async (): Promise => ({ content: [{ type: 'text', text: 'ok' }] })); + server.setRequestHandler('app/fail', { params: z.looseObject({}) }, async () => { + throw new ProtocolError(-32_002, 'resource missing'); + }); + return server; +} + +/** + * Posts an arbitrary (possibly malformed) body to the deployed 2025-era + * transport and returns the raw HTTP outcome — unlike {@link legacyLeg}, it + * does not assume the response carries a JSON error body (a 202 has none). + */ +async function legacyRawLeg(body: unknown): Promise<{ status: number; error?: LegError['error'] }> { + const server = buildServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(transport); + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body) + }) + ); + const text = await response.text(); + await server.close(); + return { + status: response.status, + ...(text.length > 0 && { error: (JSON.parse(text) as { error: LegError['error'] }).error }) + }; +} + +async function legacyLeg(body: Record): Promise { + const server = buildServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(transport); + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body) + }) + ); + const parsed = (await response.json()) as { error: LegError['error'] }; + await server.close(); + return { status: response.status, error: parsed.error }; +} + +async function modernLeg(body: Record): Promise { + const server = buildServer(); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const transport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await server.connect(transport); + const enveloped = { + ...body, + params: { ...(body['params'] as Record | undefined), _meta: ENVELOPE } + }; + const response = await transport.handleMessage(enveloped as unknown as JSONRPCRequest); + const parsed = (await response.json()) as { error: LegError['error'] }; + await server.close(); + return { status: response.status, error: parsed.error }; +} + +describe('era-parity error shapes', () => { + it.each(KNOWN_EDGE_DIVERGENCES)( + 'known divergence "$divergence": both legs answer exactly what the table enumerates', + async ({ body, legacy, modernEdge }) => { + // Legacy leg: the deployed 2025-era transport, exercised over HTTP. + const legacyActual = await legacyRawLeg(body); + expect(legacyActual.status).toBe(legacy.httpStatus); + if (legacy.code !== undefined) { + expect(legacyActual.error?.code).toBe(legacy.code); + } else { + expect(legacyActual.error).toBeUndefined(); + } + + // Modern leg: the per-request path answers these bodies at the + // edge (the inbound classifier) — they never reach a transport. + const modernActual = classifyInboundRequest({ httpMethod: 'POST', body }); + expect(modernActual.kind).toBe('reject'); + if (modernActual.kind !== 'reject') return; + expect(modernActual.httpStatus).toBe(modernEdge.httpStatus); + expect(modernActual.code).toBe(modernEdge.code); + } + ); + + it('an unknown method produces the same JSON-RPC error on both legs (status mapping is the enumerated difference)', async () => { + const input = { jsonrpc: '2.0', id: 11, method: 'definitely/unknown', params: {} }; + const legacy = await legacyLeg(input); + const modern = await modernLeg(input); + + expect(legacy.error.code).toBe(-32_601); + expect(modern.error.code).toBe(legacy.error.code); + expect(modern.error.message).toBe(legacy.error.message); + expect(modern.error.data).toEqual(legacy.error.data); + + // Enumerated difference: http-status-mapping. + expect(legacy.status).toBe(200); + expect(modern.status).toBe(404); + }); + + it('a handler-thrown protocol error produces the same in-band JSON-RPC error on both legs', async () => { + const input = { jsonrpc: '2.0', id: 12, method: 'app/fail', params: {} }; + const legacy = await legacyLeg(input); + const modern = await modernLeg(input); + + expect(legacy.status).toBe(200); + expect(modern.status).toBe(200); + expect(legacy.error).toMatchObject({ code: -32_002, message: 'resource missing' }); + expect(modern.error).toEqual(legacy.error); + }); + + it('a handler-level invalid-params rejection produces the same in-band error code on both legs', async () => { + const failingParams = new Server({ name: 'parity-params', version: '1.0.0' }, { capabilities: {} }); + // Same registration on both legs: a custom method with a params schema + // the input does not satisfy. + const register = (server: Server) => + server.setRequestHandler('app/strict', { params: z.object({ value: z.string() }) }, async params => ({ ok: params.value })); + register(failingParams); + + const legacyTransport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await failingParams.connect(legacyTransport); + const legacyResponse = await legacyTransport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 13, method: 'app/strict', params: { value: 7 } }) + }) + ); + const legacyBody = (await legacyResponse.json()) as { error: { code: number } }; + await failingParams.close(); + + const modernServer = new Server({ name: 'parity-params', version: '1.0.0' }, { capabilities: {} }); + register(modernServer); + setNegotiatedProtocolVersion(modernServer, MODERN_REVISION); + const modernTransport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await modernServer.connect(modernTransport); + const modernResponse = await modernTransport.handleMessage({ + jsonrpc: '2.0', + id: 13, + method: 'app/strict', + params: { value: 7, _meta: ENVELOPE } + } as JSONRPCRequest); + const modernBody = (await modernResponse.json()) as { error: { code: number } }; + await modernServer.close(); + + expect(legacyBody.error.code).toBe(-32_602); + expect(modernBody.error.code).toBe(legacyBody.error.code); + // Handler-level invalid params stays in-band on both legs. + expect(legacyResponse.status).toBe(200); + expect(modernResponse.status).toBe(200); + }); +}); diff --git a/packages/server/test/server/invokeSeam.test.ts b/packages/server/test/server/invokeSeam.test.ts new file mode 100644 index 0000000000..98cd86377e --- /dev/null +++ b/packages/server/test/server/invokeSeam.test.ts @@ -0,0 +1,139 @@ +/** + * The internal per-request invoke seam: one classified message in, one HTTP + * response out — value-returning and independently testable, with no HTTP + * server and no changes to protocol dispatch. + * + * The tests mark factory instances as modern-era through the package-internal + * negotiated-version hook, standing in for the HTTP entry that will own that + * write in production. + */ +import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { invoke } from '../../src/server/invoke.js'; +import { McpServer } from '../../src/server/mcp.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'invoke-seam-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const toolsCall = (name: string, args: Record): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args, _meta: ENVELOPE } + }) as JSONRPCRequest; + +function modernMcpServer(): McpServer { + const mcpServer = new McpServer({ name: 'invoke-seam-test', version: '1.0.0' }); + mcpServer.registerTool('greet', { inputSchema: z.object({ who: z.string() }) }, async ({ who }) => ({ + content: [{ type: 'text', text: `hello ${who}` }] + })); + // Stand-in for the HTTP entry, which marks factory instances as modern-era + // at binding time through the same package-internal hook. + setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION); + return mcpServer; +} + +describe('invoke', () => { + it('serves a classified request on a high-level server instance and returns the response value', async () => { + const response = await invoke(modernMcpServer(), toolsCall('greet', { who: 'world' }), { classification: MODERN }); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hello world'); + }); + + it('serves a classified request on a low-level server instance', async () => { + const server = new Server({ name: 'low-level', version: '1.0.0' }, { capabilities: {} }); + server.setRequestHandler('app/sum', { params: z.looseObject({ a: z.number(), b: z.number() }) }, async params => ({ + sum: params.a + params.b + })); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const response = await invoke( + server, + { jsonrpc: '2.0', id: 7, method: 'app/sum', params: { a: 2, b: 3, _meta: ENVELOPE } } as JSONRPCRequest, + { classification: MODERN } + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { id: number; result: { sum: number } }; + expect(body.id).toBe(7); + expect(body.result.sum).toBe(5); + }); + + it('answers an era-removed method with method-not-found and HTTP 404', async () => { + const response = await invoke( + modernMcpServer(), + { jsonrpc: '2.0', id: 2, method: 'ping', params: { _meta: ENVELOPE } } as JSONRPCRequest, + { classification: MODERN } + ); + expect(response.status).toBe(404); + const body = (await response.json()) as { error: { code: number } }; + expect(body.error.code).toBe(-32_601); + }); + + it('acknowledges classified notifications with 202 and no body', async () => { + const response = await invoke( + modernMcpServer(), + { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 99 } } as JSONRPCNotification, + { classification: MODERN } + ); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); + }); + + it('protects unmarked instances: modern-classified traffic gets the protocol-version error', async () => { + const mcpServer = new McpServer({ name: 'unmarked', version: '1.0.0' }); + mcpServer.registerTool('greet', { inputSchema: z.object({ who: z.string() }) }, async ({ who }) => ({ + content: [{ type: 'text', text: `hello ${who}` }] + })); + mcpServer.server.onerror = () => { + // the era mismatch is also surfaced out of band; irrelevant here + }; + const response = await invoke(mcpServer, toolsCall('greet', { who: 'world' }), { classification: MODERN }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; data: { supported: string[] } } }; + expect(body.error.code).toBe(-32_004); + expect(Array.isArray(body.error.data.supported)).toBe(true); + }); + + it('passes the original request and caller-supplied auth info through to handler context', async () => { + const mcpServer = new McpServer({ name: 'ctx-check', version: '1.0.0' }); + let seenAuthClientId: string | undefined; + let seenAuthorizationHeader: string | null | undefined; + mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx) => { + seenAuthClientId = ctx.http?.authInfo?.clientId; + seenAuthorizationHeader = ctx.http?.req?.headers.get('authorization'); + return { content: [{ type: 'text', text: 'ok' }] }; + }); + setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION); + + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { authorization: 'Bearer raw-header-token' } + }); + const response = await invoke(mcpServer, toolsCall('whoami', {}), { + classification: MODERN, + request, + authInfo: { token: 'verified-token', clientId: 'client-42', scopes: ['mcp'] } + }); + expect(response.status).toBe(200); + // Caller-supplied auth info arrives as-is; the raw header stays a raw + // header and is never promoted to auth info by the seam. + expect(seenAuthClientId).toBe('client-42'); + expect(seenAuthorizationHeader).toBe('Bearer raw-header-token'); + }); +}); diff --git a/packages/server/test/server/perRequestStreaming.test.ts b/packages/server/test/server/perRequestStreaming.test.ts new file mode 100644 index 0000000000..ba56b6e543 --- /dev/null +++ b/packages/server/test/server/perRequestStreaming.test.ts @@ -0,0 +1,251 @@ +/** + * Per-request streaming behavior: the lazy JSON-to-SSE upgrade, sink + * discipline (write order, drain-before-finalize, post-close drops), the + * forced response modes the entry-level knob will plug into, comment-frame + * support, and disconnect-as-cancellation. + */ +import type { CallToolResult, JSONRPCRequest, MessageClassification, ServerContext } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import type { PerRequestResponseMode } from '../../src/server/perRequestTransport.js'; +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'streaming-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const toolsCall = (id = 1): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: ENVELOPE } + }) as JSONRPCRequest; + +const progressNotification = (progress: number) => ({ + method: 'notifications/progress' as const, + params: { progressToken: 'stream-test', progress } +}); + +interface StreamingSetup { + server: Server; + transport: PerRequestHTTPServerTransport; +} + +async function setup( + handler: (ctx: ServerContext) => Promise, + responseMode?: PerRequestResponseMode +): Promise { + const server = new Server({ name: 'streaming-test', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async (_request, ctx) => handler(ctx)); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const transport = new PerRequestHTTPServerTransport({ + classification: MODERN, + ...(responseMode !== undefined && { responseMode }) + }); + await server.connect(transport); + return { server, transport }; +} + +/** SSE frames of a fully-drained response body, split on the blank-line separator. */ +async function sseFrames(response: Response): Promise { + const text = await response.text(); + return text + .split('\n\n') + .map(frame => frame.trim()) + .filter(frame => frame.length > 0); +} + +const dataOf = (frame: string): unknown => { + const dataLine = frame.split('\n').find(line => line.startsWith('data: ')); + return dataLine === undefined ? undefined : JSON.parse(dataLine.slice('data: '.length)); +}; + +describe('lazy upgrade matrix', () => { + it('answers a handler with no streamed output as a single JSON body', async () => { + const { transport } = await setup(async () => ({ content: [{ type: 'text', text: 'plain' }] })); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(response.headers.get('x-accel-buffering')).toBeNull(); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('plain'); + }); + + it('upgrades to SSE on the first related notification', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + return { content: [{ type: 'text', text: 'streamed' }] }; + }); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + expect(response.headers.get('cache-control')).toBe('no-cache'); + expect(response.headers.get('x-accel-buffering')).toBe('no'); + + const frames = await sseFrames(response); + expect(frames).toHaveLength(2); + expect(dataOf(frames[0]!)).toMatchObject({ method: 'notifications/progress' }); + expect(dataOf(frames[1]!)).toMatchObject({ id: 1, result: { content: [{ type: 'text', text: 'streamed' }] } }); + }); + + it('drains every streamed message before the terminal result and then ends the stream', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + await ctx.mcpReq.notify(progressNotification(2)); + await ctx.mcpReq.notify(progressNotification(3)); + return { content: [{ type: 'text', text: 'done' }] }; + }); + const response = await transport.handleMessage(toolsCall()); + const frames = await sseFrames(response); + expect(frames).toHaveLength(4); + const progressValues = frames.slice(0, 3).map(frame => (dataOf(frame) as { params: { progress: number } }).params.progress); + expect(progressValues).toEqual([1, 2, 3]); + expect(dataOf(frames[3]!)).toMatchObject({ result: { content: [{ type: 'text', text: 'done' }] } }); + }); + + it('emits no resumability bytes: no event ids, no retry hints, no priming events', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + return { content: [] }; + }); + const response = await transport.handleMessage(toolsCall()); + const text = await response.text(); + expect(text).not.toMatch(/^id:/m); + expect(text).not.toMatch(/^retry:/m); + expect(response.headers.get('mcp-session-id')).toBeNull(); + }); + + it('drops writes after the exchange is closed', async () => { + // A streamed exchange whose stream has already been finalized: a late + // related write must be dropped by the closed-guard. If that guard + // were removed, the write would hit the closed stream controller and + // be reported through onerror. + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + return { content: [] }; + }); + const response = await transport.handleMessage(toolsCall()); + await response.text(); + await transport.close(); + const errors: Error[] = []; + transport.onerror = error => errors.push(error); + await expect(transport.send(progressNotification(9) as never, { relatedRequestId: 1 })).resolves.toBeUndefined(); + expect(errors).toHaveLength(0); + }); +}); + +describe('forced response modes (the seam the entry-level knob plugs into)', () => { + it('sse mode opens the stream immediately, even with no streamed output', async () => { + const { transport } = await setup(async () => ({ content: [{ type: 'text', text: 'eager' }] }), 'sse'); + const response = await transport.handleMessage(toolsCall()); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + const frames = await sseFrames(response); + expect(frames).toHaveLength(1); + expect(dataOf(frames[0]!)).toMatchObject({ result: { content: [{ type: 'text', text: 'eager' }] } }); + }); + + it('sse mode still answers pre-dispatch rejections with their mapped HTTP status', async () => { + // The forced-sse stream opens only after the pre-dispatch gates pass: + // a request the validation ladder rejects (here: an unknown method + // with no handler) keeps the spec-mandated HTTP status instead of + // being framed onto a 200 stream. + const { transport } = await setup(async () => ({ content: [] }), 'sse'); + const unknownMethod = { + jsonrpc: '2.0', + id: 1, + method: 'definitely/unknown', + params: { _meta: ENVELOPE } + } as JSONRPCRequest; + const response = await transport.handleMessage(unknownMethod); + expect(response.status).toBe(404); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = (await response.json()) as { error?: { code: number } }; + expect(body.error?.code).toBe(-32_601); + }); + + it('json mode never upgrades and drops mid-call notifications', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + await ctx.mcpReq.notify(progressNotification(2)); + return { content: [{ type: 'text', text: 'json-only' }] }; + }, 'json'); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('json-only'); + // The notifications were dropped, not buffered into the body. + expect(JSON.stringify(body)).not.toContain('notifications/progress'); + }); +}); + +describe('comment frames', () => { + it('writes comment frames into an open stream and drops them otherwise', async () => { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const { transport } = await setup(async () => { + await gate; + return { content: [] }; + }, 'sse'); + + const responsePromise = transport.handleMessage(toolsCall()); + // The stream is open (sse mode settles once the pre-dispatch gates + // pass); a comment frame written now must be delivered to the + // consumer. + transport.writeCommentFrame('keep-alive'); + release(); + const response = await responsePromise; + const text = await response.text(); + expect(text).toContain(': keep-alive'); + + // After the exchange completed (and the transport closed itself), + // comment frames are dropped silently — and never surface as stream + // write errors, which is what would happen without the closed-guard. + const errors: Error[] = []; + transport.onerror = error => errors.push(error); + transport.writeCommentFrame('late'); + expect(errors).toHaveLength(0); + }); +}); + +describe('disconnect is cancellation', () => { + it('cancelling the SSE stream aborts the in-flight handler', async () => { + let observedSignal: AbortSignal | undefined; + let abortObserved!: () => void; + const aborted = new Promise(resolve => { + abortObserved = resolve; + }); + const { transport } = await setup(async ctx => { + observedSignal = ctx.mcpReq.signal; + ctx.mcpReq.signal.addEventListener('abort', () => abortObserved(), { once: true }); + await ctx.mcpReq.notify(progressNotification(1)); + await aborted; + return { content: [] }; + }); + const response = await transport.handleMessage(toolsCall()); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const reader = response.body!.getReader(); + await reader.read(); + // The client goes away: cancelling the response stream tears the + // exchange down and aborts the handler's signal. + await reader.cancel(); + await aborted; + expect(observedSignal?.aborted).toBe(true); + }); +}); diff --git a/packages/server/test/server/perRequestTransport.test.ts b/packages/server/test/server/perRequestTransport.test.ts new file mode 100644 index 0000000000..f61fd3cd00 --- /dev/null +++ b/packages/server/test/server/perRequestTransport.test.ts @@ -0,0 +1,386 @@ +/** + * The per-request HTTP server transport: single-exchange contract, the + * classification handoff into protocol dispatch, HTTP status mapping for + * pre-handler rejections, auth-info pass-through, and the close/teardown + * chain. + */ +import type { CallToolResult, JSONRPCNotification, JSONRPCRequest, MessageClassification, ServerContext } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + ProtocolError, + SdkError, + SdkErrorCode, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; +const LEGACY: MessageClassification = { era: 'legacy' }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'per-request-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +// `meta: null` builds an envelope-less request; the default is the full envelope. +const toolsCall = (id = 1, meta: Record | null = ENVELOPE): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'echo', arguments: {}, ...(meta !== null && { _meta: meta }) } + }) as JSONRPCRequest; + +const envelopedRequest = (method: string, id = 1): JSONRPCRequest => + ({ jsonrpc: '2.0', id, method, params: { _meta: ENVELOPE } }) as JSONRPCRequest; + +interface ServerSetup { + server: Server; + lastCtx: () => ServerContext | undefined; +} + +function modernServer(options: { toolsCallHandler?: (ctx: ServerContext) => Promise } = {}): ServerSetup { + const server = new Server({ name: 'per-request-test', version: '1.0.0' }, { capabilities: { tools: {} } }); + let captured: ServerContext | undefined; + const defaultHandler = async (): Promise => ({ content: [{ type: 'text', text: 'served' }] }); + server.setRequestHandler('tools/call', async (_request, ctx) => { + captured = ctx; + return (options.toolsCallHandler ?? defaultHandler)(ctx); + }); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + return { server, lastCtx: () => captured }; +} + +async function connectedTransport( + server: Server, + options?: ConstructorParameters[0] +): Promise { + const transport = new PerRequestHTTPServerTransport(options ?? { classification: MODERN }); + await server.connect(transport); + return transport; +} + +const errorOf = (body: unknown) => (body as { error?: { code: number; message: string; data?: unknown } }).error; + +describe('single-exchange contract', () => { + it('throws when a message is handled before a server is connected', async () => { + const transport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await expect(transport.handleMessage(toolsCall())).rejects.toThrow(/not connected/); + }); + + it('serves exactly one exchange — a second handleMessage throws', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + const first = await transport.handleMessage(toolsCall()); + expect(first.status).toBe(200); + await expect(transport.handleMessage(toolsCall(2))).rejects.toThrow(/exactly one exchange/); + }); + + it('cannot be started twice', async () => { + const transport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await transport.start(); + await expect(transport.start()).rejects.toThrow(/already started/); + }); + + it('answers notification POST bodies with 202 and no body', async () => { + const { server } = modernServer(); + let delivered: string | undefined; + server.fallbackNotificationHandler = async notification => { + delivered = notification.method; + }; + const transport = await connectedTransport(server); + const response = await transport.handleMessage({ jsonrpc: '2.0', method: 'demo/heartbeat' } as JSONRPCNotification); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(delivered).toBe('demo/heartbeat'); + await transport.close(); + await server.close(); + }); +}); + +describe('classification handoff into dispatch', () => { + it('serves a modern-classified request on a modern-marked instance', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('served'); + }); + + it('answers legacy-classified traffic on a modern-marked instance with the protocol-version error and HTTP 400', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server, { classification: LEGACY }); + server.onerror = () => { + // The mismatch is also surfaced out of band; irrelevant here. + }; + const response = await transport.handleMessage(toolsCall(1, null)); + expect(response.status).toBe(400); + const error = errorOf(await response.json()); + expect(error?.code).toBe(-32_004); + expect(error?.data).toMatchObject({ requested: expect.any(String), supported: expect.any(Array) }); + }); + + it('answers modern-classified traffic on an unmarked (legacy) instance with the protocol-version error', async () => { + const server = new Server({ name: 'unmarked', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async () => ({ content: [] })); + server.onerror = () => { + // The mismatch is also surfaced out of band; irrelevant here. + }; + const transport = await connectedTransport(server, { classification: MODERN }); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(400); + expect(errorOf(await response.json())?.code).toBe(-32_004); + }); +}); + +describe('HTTP status mapping', () => { + it('maps method-not-found for an era-removed method to HTTP 404', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + // `ping` exists on the 2025 era but has no entry on the 2026 registry. + const response = await transport.handleMessage(envelopedRequest('ping')); + expect(response.status).toBe(404); + expect(errorOf(await response.json())).toMatchObject({ code: -32_601, message: 'Method not found' }); + }); + + it('maps method-not-found for an unknown method with no handler to HTTP 404', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(envelopedRequest('definitely/unknown')); + expect(response.status).toBe(404); + expect(errorOf(await response.json())?.code).toBe(-32_601); + }); + + it('keeps handler-produced errors in-band on HTTP 200, whatever their code', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_002, 'resource missing'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())).toMatchObject({ code: -32_002, message: 'resource missing' }); + }); + + it('keeps a handler-thrown method-not-found error in-band on HTTP 200 (the status table is origin-keyed)', async () => { + // A handler relaying a downstream -32601 (a proxy/relay tool is the + // realistic case) is a handler-produced error: it must not be + // re-mapped to HTTP 404 just because the ladder table maps that code + // for ladder-originated rejections. + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_601, 'Method not found'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())).toMatchObject({ code: -32_601, message: 'Method not found' }); + }); + + it('keeps a handler-thrown unsupported-protocol-version error in-band on HTTP 200', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_004, 'Unsupported protocol version: 2099-01-01'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())?.code).toBe(-32_004); + }); + + it('keeps handler-produced invalid-params errors in-band on HTTP 200 (never status-mapped)', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_602, 'bad arguments'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())?.code).toBe(-32_602); + }); + + it('keeps the dispatch-level envelope check in-band: only the edge classifier maps invalid params to 400', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + // Modern-classified request without the _meta envelope: the dispatch + // layer rejects it with invalid params; the transport does not turn + // that into an HTTP-level failure. + const response = await transport.handleMessage(toolsCall(1, null)); + expect(response.status).toBe(200); + expect(errorOf(await response.json())?.code).toBe(-32_602); + }); +}); + +describe('auth info is strictly pass-through', () => { + it('never derives authInfo from the inbound request headers', async () => { + const { server, lastCtx } = modernServer(); + const transport = await connectedTransport(server); + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { authorization: 'Bearer super-secret-token', 'content-type': 'application/json' } + }); + const response = await transport.handleMessage(toolsCall(), { request }); + expect(response.status).toBe(200); + const ctx = lastCtx(); + expect(ctx?.http?.req).toBe(request); + // The Authorization header is visible on the raw request, but it is + // never promoted to validated auth info by the transport. + expect(ctx?.http?.req?.headers.get('authorization')).toBe('Bearer super-secret-token'); + expect(ctx?.http?.authInfo).toBeUndefined(); + }); + + it('surfaces caller-provided authInfo unchanged', async () => { + const { server, lastCtx } = modernServer(); + const transport = await connectedTransport(server); + const authInfo = { token: 'validated-token', clientId: 'client-1', scopes: ['mcp'] }; + const response = await transport.handleMessage(toolsCall(), { authInfo }); + expect(response.status).toBe(200); + expect(lastCtx()?.http?.authInfo).toEqual(authInfo); + }); +}); + +describe('teardown and the close chain', () => { + it('close is idempotent and fires onclose exactly once', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + let closes = 0; + const previous = transport.onclose; + transport.onclose = () => { + closes += 1; + previous?.(); + }; + await transport.close(); + await transport.close(); + expect(closes).toBe(1); + }); + + it('server.close() and transport.close() do not re-enter each other', async () => { + const first = modernServer(); + const firstTransport = await connectedTransport(first.server); + await first.server.close(); + await firstTransport.close(); + + const second = modernServer(); + const secondTransport = await connectedTransport(second.server); + await secondTransport.close(); + await second.server.close(); + }); + + it('closing mid-request rejects the pending response and aborts the handler', async () => { + let observedSignal: AbortSignal | undefined; + const { server } = modernServer({ + toolsCallHandler: ctx => { + observedSignal = ctx.mcpReq.signal; + return new Promise(() => { + // never resolves; the exchange is torn down externally + }); + } + }); + const transport = await connectedTransport(server); + const pending = transport.handleMessage(toolsCall()); + const expectation = expect(pending).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed + ); + await new Promise(resolve => setTimeout(resolve, 5)); + await transport.close(); + await expectation; + expect(observedSignal?.aborted).toBe(true); + }); + + it('an aborted request signal cancels the exchange', async () => { + let observedSignal: AbortSignal | undefined; + const { server } = modernServer({ + toolsCallHandler: ctx => { + observedSignal = ctx.mcpReq.signal; + return new Promise(() => { + // parked until the client goes away + }); + } + }); + const transport = await connectedTransport(server); + const abortController = new AbortController(); + const request = new Request('http://localhost/mcp', { method: 'POST', signal: abortController.signal }); + const pending = transport.handleMessage(toolsCall(), { request }); + const expectation = expect(pending).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed + ); + await new Promise(resolve => setTimeout(resolve, 5)); + abortController.abort(); + await expectation; + expect(observedSignal?.aborted).toBe(true); + }); + + it('rejects with the typed connection-closed error when the request signal is already aborted', async () => { + const { server, lastCtx } = modernServer(); + const transport = await connectedTransport(server); + const abortController = new AbortController(); + abortController.abort(); + const request = new Request('http://localhost/mcp', { method: 'POST', signal: abortController.signal }); + await expect(transport.handleMessage(toolsCall(), { request })).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed + ); + // The handler never ran; the exchange was torn down before dispatch. + expect(lastCtx()).toBeUndefined(); + }); + + it('drops writes after close without raising or reporting through onerror', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + await transport.close(); + // If the closed-guard were removed, this response (for a request the + // transport never saw) would be reported through onerror as an + // unknown-request-id write. + const errors: Error[] = []; + transport.onerror = error => errors.push(error); + await expect(transport.send({ jsonrpc: '2.0', id: 1, result: {} }, { relatedRequestId: 1 })).resolves.toBeUndefined(); + expect(errors).toHaveLength(0); + }); + + it('drops messages unrelated to the in-flight request', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => ({ content: [{ type: 'text', text: 'done' }] }) + }); + const transport = await connectedTransport(server); + const pending = transport.handleMessage(toolsCall()); + // A session-wide notification with no related request has nowhere to + // go on a per-request exchange. + await transport.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }); + const response = await pending; + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + }); +}); + +describe('custom-method requests', () => { + it('serves custom (extension) methods registered with explicit schemas', async () => { + const { server } = modernServer(); + server.setRequestHandler('app/echo', { params: z.looseObject({ value: z.string() }) }, async params => ({ + echoed: params.value + })); + const transport = await connectedTransport(server); + const response = await transport.handleMessage({ + jsonrpc: '2.0', + id: 4, + method: 'app/echo', + params: { value: 'hello', _meta: ENVELOPE } + } as JSONRPCRequest); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { echoed: string } }; + expect(body.result.echoed).toBe('hello'); + }); +}); From a7e8f59fb32b872d859466222819b6c28f4c0ed2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:06:58 +0100 Subject: [PATCH 14/37] feat(server): createMcpHandler entry point, legacy slot model, and origin validation middleware (#2304) --- .changeset/add-version-negotiation-option.md | 3 +- .changeset/create-mcp-handler.md | 10 + .../deprecate-client-identity-accessors.md | 6 + .changeset/origin-validation-middleware.md | 12 + docs/migration-SKILL.md | 7 + docs/migration.md | 70 +- docs/server.md | 6 +- examples/server/src/dualEraStreamableHttp.ts | 96 ++ packages/client/src/client/probeClassifier.ts | 9 +- .../legacyHandshakeModernOnlyGuard.test.ts | 47 + packages/core/src/index.ts | 4 +- packages/core/src/shared/protocol.ts | 6 +- packages/middleware/express/src/express.ts | 24 +- packages/middleware/express/src/index.ts | 1 + .../src/middleware/originValidation.ts | 52 ++ .../express/test/originValidation.test.ts | 171 ++++ packages/middleware/fastify/src/fastify.ts | 24 +- packages/middleware/fastify/src/index.ts | 1 + .../src/middleware/originValidation.ts | 50 ++ .../fastify/test/originValidation.test.ts | 116 +++ packages/middleware/hono/src/hono.ts | 24 +- packages/middleware/hono/src/index.ts | 1 + .../hono/src/middleware/originValidation.ts | 38 + .../hono/test/originValidation.test.ts | 91 ++ packages/middleware/node/src/index.ts | 2 + .../src/middleware/hostHeaderValidation.ts | 53 ++ .../node/src/middleware/originValidation.ts | 54 ++ .../middleware/node/test/validation.test.ts | 79 ++ packages/server/src/index.ts | 28 + .../server/src/server/createMcpHandler.ts | 760 ++++++++++++++++ .../src/server/middleware/originValidation.ts | 98 ++ packages/server/src/server/server.ts | 83 ++ .../test/server/createMcpHandler.test.ts | 849 ++++++++++++++++++ .../createMcpHandlerStatelessLiteral.test.ts | 83 ++ .../server/legacyStatelessFallback.test.ts | 184 ++++ .../test/server/originValidation.test.ts | 67 ++ .../test/server/createMcpHandler.test.ts | 165 ++++ 37 files changed, 3358 insertions(+), 16 deletions(-) create mode 100644 .changeset/create-mcp-handler.md create mode 100644 .changeset/deprecate-client-identity-accessors.md create mode 100644 .changeset/origin-validation-middleware.md create mode 100644 examples/server/src/dualEraStreamableHttp.ts create mode 100644 packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts create mode 100644 packages/middleware/express/src/middleware/originValidation.ts create mode 100644 packages/middleware/express/test/originValidation.test.ts create mode 100644 packages/middleware/fastify/src/middleware/originValidation.ts create mode 100644 packages/middleware/fastify/test/originValidation.test.ts create mode 100644 packages/middleware/hono/src/middleware/originValidation.ts create mode 100644 packages/middleware/hono/test/originValidation.test.ts create mode 100644 packages/middleware/node/src/middleware/hostHeaderValidation.ts create mode 100644 packages/middleware/node/src/middleware/originValidation.ts create mode 100644 packages/middleware/node/test/validation.test.ts create mode 100644 packages/server/src/server/createMcpHandler.ts create mode 100644 packages/server/src/server/middleware/originValidation.ts create mode 100644 packages/server/test/server/createMcpHandler.test.ts create mode 100644 packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts create mode 100644 packages/server/test/server/legacyStatelessFallback.test.ts create mode 100644 packages/server/test/server/originValidation.test.ts create mode 100644 test/integration/test/server/createMcpHandler.test.ts diff --git a/.changeset/add-version-negotiation-option.md b/.changeset/add-version-negotiation-option.md index 1c81a2b294..334796f879 100644 --- a/.changeset/add-version-negotiation-option.md +++ b/.changeset/add-version-negotiation-option.md @@ -4,7 +4,8 @@ --- Add opt-in protocol version negotiation on `ClientOptions.versionNegotiation`. The default is unchanged: without the option (or with `mode: 'legacy'`) the client performs today's 2025 connect sequence byte-identically. `mode: 'auto'` probes the server with `server/discover` at -connect time and conservatively falls back to the plain legacy `initialize` handshake on the same connection unless the outcome is definitive modern evidence; a network outage rejects with a typed connect error, and a probe timeout is transport-aware — on stdio it indicates +connect time and conservatively falls back to the plain legacy `initialize` handshake on the same connection unless the outcome is definitive modern evidence (with a supported-versions list that has no 2025-era entry there is nothing to fall back to, and connect rejects +with a typed error instead); a network outage rejects with a typed connect error, and a probe timeout is transport-aware — on stdio it indicates a legacy server and falls back to `initialize` on the same stream, on HTTP it rejects with a typed timeout error. `mode: { pin: '' }` negotiates exactly the pinned modern revision with no fallback. Probe policy lives under `probe: { timeoutMs? }` — the probe inherits the standard request timeout. The probe's `MCP-Protocol-Version`/`Mcp-Method` headers derive from the probe message body; the transport version slot is never touched during negotiation, so legacy-era traffic carries zero 2026 headers by construction. Adds the `SdkErrorCode.EraNegotiationFailed` code for negotiation-phase connect failures. diff --git a/.changeset/create-mcp-handler.md b/.changeset/create-mcp-handler.md new file mode 100644 index 0000000000..d103fb0ac1 --- /dev/null +++ b/.changeset/create-mcp-handler.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add `createMcpHandler(factory, { legacy?, onerror?, responseMode? })`, an HTTP entry point that serves the 2026-07-28 draft revision per request: each envelope-carrying request is classified once, served on a fresh instance from the factory bound to the claimed revision, +and answered with a JSON body or a lazily-upgraded SSE stream. 2025-era serving is opt-in through the `legacy` slot (`'stateless'` for per-request stateless serving via the existing streamable HTTP transport, or any fetch-shaped handler for bring-your-own wiring); without +the slot the endpoint is modern-only and rejects 2025-era requests with the unsupported-protocol-version error naming its supported revisions. The handler exposes a web-standard `fetch(request, { authInfo?, parsedBody? })` face and a duck-typed `node(req, res, parsedBody?)` +face, plus `close()` for tearing down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the canonical slot value), the `PerRequestHTTPServerTransport` single-exchange transport and the `classifyInboundRequest` classifier for hand-wired compositions, and +the supporting types. `responseMode: 'json'` never streams and drops mid-call notifications (progress, logging and other related messages emitted before the result); listen-class subscription streams are always served over SSE. The entry performs no Origin/Host validation +(use the middleware packages) and no token verification — `authInfo` is pass-through and never derived from request headers. diff --git a/.changeset/deprecate-client-identity-accessors.md b/.changeset/deprecate-client-identity-accessors.md new file mode 100644 index 0000000000..8b73104076 --- /dev/null +++ b/.changeset/deprecate-client-identity-accessors.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Deprecate `Server.getClientCapabilities()`, `Server.getClientVersion()` and `Server.getNegotiatedProtocolVersion()` in favor of the per-request handler context: on 2026-07-28 requests the validated `_meta` envelope carries the client's identity (`ctx.mcpReq.envelope`), +and instances serving that revision through `createMcpHandler` are backfilled per request so the accessors keep answering. Behavior on 2025-era connections is unchanged; the accessors remain functional. diff --git a/.changeset/origin-validation-middleware.md b/.changeset/origin-validation-middleware.md new file mode 100644 index 0000000000..7f484d7412 --- /dev/null +++ b/.changeset/origin-validation-middleware.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/express': minor +'@modelcontextprotocol/hono': minor +'@modelcontextprotocol/fastify': minor +'@modelcontextprotocol/node': minor +--- + +Add Origin header validation alongside the existing Host header validation. The server package gains framework-agnostic helpers (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`); the Express, Hono and Fastify adapters gain `originValidation` / +`localhostOriginValidation` middleware and a new `allowedOrigins` option on their app factories, which now arm Origin validation by default for localhost-class binds (mirroring the Host validation ladder; the 0.0.0.0-without-allowlist warning is unchanged). Requests +without an `Origin` header pass — non-browser MCP clients are unaffected — while a present `Origin` that is not allowed or cannot be parsed (including the opaque `null` origin) is rejected with `403`. The Node adapter ships `hostHeaderValidation` / `originValidation` +request guards for plain `node:http` servers, which previously had no validation helpers. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index c906b0bc7d..c708618ef0 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -543,6 +543,13 @@ No code changes required; these are wire-behavior notes: - Resumability behavior (SSE priming events, `closeSSEStream` / `closeStandaloneSSEStream` callbacks) is only enabled for protocol versions in the transport's supported-versions list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` request body no longer enable it. Behavior for all currently supported protocol versions is unchanged. - Session-ID mismatch still responds `404 Not Found` with JSON-RPC error code `-32001` (`Session not found`), unchanged from v1. This `-32001` usage is an SDK convention, not a spec-assigned code, and may be re-derived as 2026 protocol revision error handling is adopted — migrated client code should key off the HTTP `404` status, not the `-32001` code. +### Server (deprecated accessors and app-factory Origin validation) + +These can require code changes: + +- `Server.getClientCapabilities()`, `getClientVersion()` and `getNegotiatedProtocolVersion()` are deprecated but functional: prefer the per-request context (`ctx.mcpReq.envelope`) on 2026-07-28 requests. No mechanical change required yet; plan the move before the deprecations are removed. +- `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default (requests without an `Origin` header are unaffected). Browser-served clients on a non-localhost origin need `allowedOrigins: [...]`, which replaces the default localhost allowlist — Origin validation cannot be disabled for localhost-class binds. + ## 14. Runtime-Specific JSON Schema Validators (Enhancement) The SDK now auto-selects the appropriate JSON Schema validator based on runtime: diff --git a/docs/migration.md b/docs/migration.md index 67f24e19e6..6705b0b0a9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1009,8 +1009,8 @@ How the modes behave: handshake **on the same connection** — byte-equivalent to a 2025 client, including the `initialize` body version and with zero 2026 headers. The probe costs one round trip against an old server and nothing else. - **`mode: { pin: '2026-07-28' }`**: modern era at exactly that revision. No fallback — if the server does not offer the pinned version, `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). -Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era, while a network outage rejects with a typed connect error (`SdkError` -with `EraNegotiationFailed`). A probe timeout is transport-aware, following the specification's backward-compatibility rules: on **stdio**, a server that does not answer the probe within the timeout is treated as a legacy server (some legacy servers never respond to unknown +Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era — provided the supported-versions list still contains a 2025-era +revision; with a modern-only list there is nothing to fall back to and `connect()` rejects with the typed negotiation error instead — while a network outage rejects with a typed connect error (`SdkError` with `EraNegotiationFailed`). A probe timeout is transport-aware, following the specification's backward-compatibility rules: on **stdio**, a server that does not answer the probe within the timeout is treated as a legacy server (some legacy servers never respond to unknown pre-`initialize` requests at all) and the client falls back to `initialize` on the same stream; on **HTTP**, where a deployed server answers and silence means an outage, the timeout rejects with a typed `RequestTimeout` error — a dead HTTP server is never misreported as a legacy server. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers and the legacy handshake sends none of them. @@ -1026,9 +1026,69 @@ versionNegotiation: { ``` On the server side, a `Server`/`McpServer` whose `supportedProtocolVersions` list includes a 2026-era revision installs a `server/discover` handler, advertising only its modern revisions; servers with the default version list are byte-identical to before (they keep -answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Note that serving the 2026 revision to ordinary HTTP/stdio traffic arrives with an upcoming server-side entry -point: today the negotiation surface is client-side, and `mode: 'auto'` falls back cleanly against current SDK servers. The client can also issue the request directly via `client.discover()` on a 2026-era connection — though a full typed round-trip against an SDK -server additionally needs the per-request envelope support that lands with that server entry — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. +answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the +next section; serving it over stdio arrives with a later release. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation probe +already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. + +### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` + +The server package now ships an HTTP entry point that serves the 2026-07-28 draft revision per request, with 2025-era serving available as an **opt-in** slot: + +```typescript +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler( + ctx => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once — the same factory backs both eras + return server; + }, + { legacy: 'stateless' } +); + +// Web-standard runtimes (Cloudflare Workers, Deno, Bun, Hono): +// handler.fetch(request) +// Node frameworks (Express, Fastify, plain node:http): +// handler.node(req, res, req.body) +``` + +How the `legacy` slot behaves: + +- **omitted** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no silent 2025 + serving without the slot.** +- **`legacy: 'stateless'`** — 2025-era traffic is additionally served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only `sessionIdGenerator: undefined`. The exported + `legacyStatelessFallback(factory)` is the same handler as a standalone value. +- **`legacy: `** — bring your own legacy serving (for example an existing sessionful `WebStandardStreamableHTTPServerTransport` wiring). Requests are handed to it untouched and its lifecycle stays yours. + +The optional `responseMode` controls how modern request exchanges are answered: `'auto'` (default) returns a single JSON body and lazily upgrades to an SSE stream when the handler emits a related message before its result; `'sse'` always streams; **`'json'` never streams +and DROPS mid-call notifications** (progress, logging, and any other related message emitted before the result) — only the terminal result is delivered. Subscription (listen-class) streams are always served over SSE regardless of the setting. `onerror` receives +out-of-band errors and rejected requests for logging. + +The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` / attached as `req.auth` on the Node face is forwarded to handlers as-is and never derived from +request headers. Power users who want to compose routing themselves can use the exported `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around +(`const { fetch } = handler`). + +### Client identity accessors deprecated in favor of per-request context + +`Server.getClientCapabilities()`, `Server.getClientVersion()` and `Server.getNegotiatedProtocolVersion()` are deprecated (they remain functional). On 2026-07-28 requests the client's identity travels with each request in the validated `_meta` envelope and is available to +handlers as `ctx.mcpReq.envelope`; instances serving that revision through `createMcpHandler` are backfilled per request, so existing code that calls the accessors keeps working on both eras. On 2025-era connections the accessors keep returning the `initialize`-scoped +values, as before. + +### Origin validation middleware and default arming + +The middleware packages now ship Origin header validation alongside the existing Host header validation, and the app factories arm it by default for localhost-class binds: + +```typescript +import { originValidation, localhostOriginValidation } from '@modelcontextprotocol/express'; // also @modelcontextprotocol/hono, /fastify + +const app = createMcpExpressApp(); // localhost bind: Host AND Origin validation armed by default +const appCustom = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); +``` + +Requests without an `Origin` header pass unchanged (MCP clients outside a browser do not send one), so non-browser traffic is unaffected. A present `Origin` whose hostname is not allowed — or that cannot be parsed, including the opaque `null` origin — is rejected with +`403` (deny on failure). For a localhost-bound factory app there is no switch that turns Origin validation off: passing an explicit `allowedOrigins` list replaces the default localhost allowlist (use it to allow additional origins, such as a deployed web frontend), and +validation stays armed. The framework-agnostic helpers (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) live in +`@modelcontextprotocol/server` for bare web-standard mounts, and `@modelcontextprotocol/node` now ships request guards (`hostHeaderValidation`, `originValidation` and their `localhost*` variants) for plain `node:http` servers, which previously had no validation helpers. ### Automatic JSON Schema validator selection by runtime diff --git a/docs/server.md b/docs/server.md index d03a6735a5..9110d87379 100644 --- a/docs/server.md +++ b/docs/server.md @@ -598,7 +598,11 @@ const app = createMcpExpressApp({ `createMcpHonoApp()` from `@modelcontextprotocol/hono` provides the same protection for Hono-based servers and Web Standard runtimes (Cloudflare Workers, Deno, Bun). -If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) middleware source for reference. +The app factories also validate the `Origin` header with the same arming rules: localhost-class binds are protected by default, and an explicit `allowedOrigins` list (hostnames, port-agnostic — the same convention as `allowedHosts`) replaces the default localhost allowlist; there is no option that disables Origin validation for a localhost-class bind. Requests without +an `Origin` header always pass, so MCP clients outside a browser are unaffected; a present `Origin` that is not allowed, or that cannot be parsed, is rejected with `403`. The per-framework middleware (`originValidation`, `localhostOriginValidation`) can also be mounted +explicitly, and `@modelcontextprotocol/node` ships equivalent request guards for plain `node:http` servers. + +If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) middleware source for reference. When mounting a handler bare on a fetch-native runtime, the framework-agnostic helpers from `@modelcontextprotocol/server` (`hostHeaderValidationResponse`, `originValidationResponse`) cover the same checks before the request reaches the handler. ## See also diff --git a/examples/server/src/dualEraStreamableHttp.ts b/examples/server/src/dualEraStreamableHttp.ts new file mode 100644 index 0000000000..b10121ebad --- /dev/null +++ b/examples/server/src/dualEraStreamableHttp.ts @@ -0,0 +1,96 @@ +/** + * Dual-era HTTP serving with `createMcpHandler`: one factory, one endpoint, + * both protocol eras. + * + * The same factory backs every serving mode; the `MCP_LEGACY_MODE` environment + * variable selects how 2025-era (non-envelope) traffic is handled: + * + * - `MCP_LEGACY_MODE=none` → modern-only strict: 2026-07-28 requests are + * served, 2025-era requests get the documented + * rejection naming the supported revisions. + * - `MCP_LEGACY_MODE=stateless` → (default) 2025-era traffic is additionally + * served per-request via the stateless idiom. + * - `MCP_LEGACY_MODE=byo` → the same, but wired explicitly through the + * exported `legacyStatelessFallback` slot value + * (stand-in for bringing your own legacy handler, + * e.g. an existing sessionful wiring). + * + * Run with `tsx examples/server/src/dualEraStreamableHttp.ts`, then point any + * plain 2025 client at http://localhost:3000/mcp (served through the legacy + * slot when one is configured). A `versionNegotiation: { mode: 'auto' }` + * client negotiates 2026-07-28 against the same endpoint, but automatic + * envelope emission for every request is still a client-side follow-up: + * ordinary typed calls (for example `callTool`) must attach the per-request + * `_meta` envelope explicitly for now (see + * `test/integration/test/server/createMcpHandler.test.ts` for the pattern), + * or the endpoint rejects them on the header/body cross-check. + */ +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import type { CallToolResult, CreateMcpHandlerOptions, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, legacyStatelessFallback, McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +// One factory for both legs (and every slot state): tools are defined once and +// served identically to 2025-era and 2026-era clients. +const getServer = (ctx: McpRequestContext) => { + const server = new McpServer( + { + name: 'dual-era-server', + version: '1.0.0' + }, + { capabilities: { tools: {} }, instructions: 'A small dual-era demo server.' } + ); + + server.registerTool( + 'greet', + { + description: 'Greets the caller and reports which protocol era served the request', + inputSchema: z.object({ name: z.string().describe('Name to greet') }) + }, + async ({ name }): Promise => ({ + content: [{ type: 'text', text: `Hello, ${name}! (served on the ${ctx.era} protocol era)` }] + }) + ); + + return server; +}; + +const legacyMode = process.env.MCP_LEGACY_MODE ?? 'stateless'; +const options: CreateMcpHandlerOptions = { + onerror: error => console.error('MCP handler error:', error.message) +}; +if (legacyMode === 'stateless') { + options.legacy = 'stateless'; +} else if (legacyMode === 'byo') { + // Bring-your-own legacy serving: any fetch-shaped handler works here. The + // canonical stateless fallback doubles as the simplest BYO value; an + // existing sessionful streamable HTTP wiring would be passed the same way. + options.legacy = legacyStatelessFallback(getServer); +} + +const handler = createMcpHandler(getServer, options); + +// Origin/Host validation is middleware, not entry, concern: the Express app +// factory arms both for localhost binds by default. +const app = createMcpExpressApp(); + +app.all('/mcp', (req: Request, res: Response) => { + void handler.node(req, res, req.body); +}); + +const PORT = 3000; +app.listen(PORT, error => { + if (error) { + console.error('Failed to start server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`Dual-era MCP server listening on http://localhost:${PORT}/mcp (legacy mode: ${legacyMode})`); +}); + +process.on('SIGINT', async () => { + await handler.close(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}); diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index 950174fdc6..25dd025a3b 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -55,8 +55,13 @@ export interface ProbeClassifierContext { requestedVersion: string; /** * Whether a legacy `initialize` fallback is possible — `false` for a - * modern-only client and for `pin` mode, where rows that would otherwise - * fall back yield a typed `UnsupportedProtocolVersionError` instead. + * modern-only client and for `pin` mode. Without a fallback, rows carrying + * modern evidence but no usable version overlap — a `DiscoverResult` with + * no overlapping version, or a `-32004` whose `data.supported` lists only + * legacy revisions — yield a typed `UnsupportedProtocolVersionError` built + * from that evidence; the remaining rows that would have fallen back still + * classify as `legacy`, and the caller reports them as a typed negotiation + * error instead of starting an `initialize` handshake. */ fallbackAvailable: boolean; /** See {@linkcode ProbeEnvironment}. */ diff --git a/packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts b/packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts new file mode 100644 index 0000000000..cf6c34af2b --- /dev/null +++ b/packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts @@ -0,0 +1,47 @@ +/** + * Plain-path guard for modern-only supported-versions lists: a Client + * constructed WITHOUT versionNegotiation must never offer a 2026-era revision + * through the legacy `initialize` handshake. With no 2025-era entry to offer, + * connect() rejects with the typed negotiation error before anything reaches + * the wire — independently of the same guard on the auto-negotiation path. + */ +import type { JSONRPCMessage, MessageExtraInfo, Transport } from '@modelcontextprotocol/core'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +function recordingTransport(): Transport & { sent: JSONRPCMessage[] } { + const sent: JSONRPCMessage[] = []; + return { + sent, + async start() { + // nothing to start + }, + async send(message: JSONRPCMessage) { + sent.push(message); + }, + async close() { + // nothing to close + }, + onclose: undefined, + onerror: undefined, + onmessage: undefined as ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined + }; +} + +describe('plain client with a modern-only supported-versions list', () => { + test.each([ + { label: "['2026-07-28']", supportedProtocolVersions: ['2026-07-28'] }, + { label: '[] (empty list)', supportedProtocolVersions: [] as string[] } + ])('connect() rejects with the typed negotiation error and never sends initialize — $label', async ({ supportedProtocolVersions }) => { + const transport = recordingTransport(); + const client = new Client({ name: 'modern-only-client', version: '1.0.0' }, { supportedProtocolVersions }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + + expect(transport.sent.filter(message => 'method' in message && message.method === 'initialize')).toHaveLength(0); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 51d8204fe4..e85490f204 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,8 +14,8 @@ export * from './shared/uriTemplate.js'; export * from './types/index.js'; export * from './util/inMemory.js'; // Wire-codec internals: ONLY the version→codec resolver the sibling packages -// need (era state itself lives on Protocol and is reached through the -// package-internal accessors exported by shared/protocol.ts). Nothing +// need (era state itself lives on Protocol and is written through the +// package-internal write hook exported by shared/protocol.ts). Nothing // per-revision (schemas, registries, codec objects) is ever exported — not // even on this internal barrel — so per-era vocabulary cannot leak toward the // public surface. diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 96f9aa67e8..bf2e5b95b9 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -61,8 +61,10 @@ export type ProgressCallback = (progress: Progress) => void; */ export type ProtocolOptions = { /** - * Protocol versions supported. First version is preferred (sent by client, - * used as fallback by server). Passed to transport during {@linkcode Protocol.connect | connect()}. + * Protocol versions supported. The legacy `initialize` handshake offers and + * falls back to the first 2025-era entry in the list (the client sends it, + * the server counter-offers it); 2026-era entries are only ever selected via + * `server/discover`. Passed to transport during {@linkcode Protocol.connect | connect()}. * * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} */ diff --git a/packages/middleware/express/src/express.ts b/packages/middleware/express/src/express.ts index 252502952b..70d3881d0b 100644 --- a/packages/middleware/express/src/express.ts +++ b/packages/middleware/express/src/express.ts @@ -2,6 +2,7 @@ import type { Express } from 'express'; import express from 'express'; import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; +import { localhostOriginValidation, originValidation } from './middleware/originValidation.js'; /** * Options for creating an MCP Express application. @@ -23,6 +24,18 @@ export interface CreateMcpExpressAppOptions { */ allowedHosts?: string[]; + /** + * List of allowed origin hostnames for Origin header validation. + * If provided, Origin validation will be applied using this list (port-agnostic, + * hostnames only — the same convention as `allowedHosts`). + * + * When omitted, Origin validation is automatically enabled for localhost-class + * binds (the same condition as host validation): requests without an `Origin` + * header pass, while a present `Origin` whose hostname is not localhost-class + * is rejected with `403`. + */ + allowedOrigins?: string[]; + /** * Controls the maximum request body size for the JSON body parser. * Passed directly to Express's `express.json({ limit })` option. @@ -60,7 +73,7 @@ export interface CreateMcpExpressAppOptions { * ``` */ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express { - const { host = '127.0.0.1', allowedHosts, jsonLimit } = options; + const { host = '127.0.0.1', allowedHosts, allowedOrigins, jsonLimit } = options; const app = express(); app.use(express.json(jsonLimit ? { limit: jsonLimit } : undefined)); @@ -84,5 +97,14 @@ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): E } } + // Origin validation follows the same arming ladder as host validation: + // an explicit allowlist wins; otherwise localhost-class binds are protected + // by default. Requests without an Origin header always pass. + if (allowedOrigins) { + app.use(originValidation(allowedOrigins)); + } else if (['127.0.0.1', 'localhost', '::1'].includes(host)) { + app.use(localhostOriginValidation()); + } + return app; } diff --git a/packages/middleware/express/src/index.ts b/packages/middleware/express/src/index.ts index d2742ce782..941354d4ab 100644 --- a/packages/middleware/express/src/index.ts +++ b/packages/middleware/express/src/index.ts @@ -1,5 +1,6 @@ export * from './express.js'; export * from './middleware/hostHeaderValidation.js'; +export * from './middleware/originValidation.js'; // OAuth Resource-Server glue: bearer-token middleware + PRM/AS metadata router. export type { BearerAuthMiddlewareOptions } from './auth/bearerAuth.js'; diff --git a/packages/middleware/express/src/middleware/originValidation.ts b/packages/middleware/express/src/middleware/originValidation.ts new file mode 100644 index 0000000000..d92513ae6c --- /dev/null +++ b/packages/middleware/express/src/middleware/originValidation.ts @@ -0,0 +1,52 @@ +import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; + +/** + * Express middleware for Origin header validation. + * Validates the `Origin` header hostname (port-agnostic) against an allowed list. + * + * Browsers attach an `Origin` header to cross-origin requests; validating it — + * alongside Host header validation — protects localhost and development servers + * against DNS rebinding and cross-site request forgery. Requests without an + * `Origin` header pass (non-browser MCP clients do not send one); a present + * value that is not allowed, or that cannot be parsed, is rejected with `403`. + * + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port). + * For IPv6, provide the address with brackets (e.g., `[::1]`). + * @returns Express middleware function + * + * @example + * ```ts + * app.use(originValidation(['localhost', '127.0.0.1', '[::1]'])); + * ``` + */ +export function originValidation(allowedOriginHostnames: string[]): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + const result = validateOriginHeader(req.headers.origin, allowedOriginHostnames); + if (!result.ok) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }); + return; + } + next(); + }; +} + +/** + * Convenience middleware for localhost Origin validation. + * Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost). + * + * @example + * ```ts + * app.use(localhostOriginValidation()); + * ``` + */ +export function localhostOriginValidation(): RequestHandler { + return originValidation(localhostAllowedOrigins()); +} diff --git a/packages/middleware/express/test/originValidation.test.ts b/packages/middleware/express/test/originValidation.test.ts new file mode 100644 index 0000000000..5184adf0aa --- /dev/null +++ b/packages/middleware/express/test/originValidation.test.ts @@ -0,0 +1,171 @@ +import type { NextFunction, Request, Response } from 'express'; +import supertest from 'supertest'; +import { vi } from 'vitest'; + +import { createMcpExpressApp } from '../src/express.js'; +import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation.js'; + +// Helper to create mock Express request/response/next +function createMockReqResNext(origin?: string) { + const req = { + headers: { + origin + } + } as Request; + + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis() + } as unknown as Response; + + const next = vi.fn() as NextFunction; + + return { req, res, next }; +} + +describe('@modelcontextprotocol/express origin validation', () => { + describe('originValidation', () => { + test('should block a disallowed Origin header', () => { + const middleware = originValidation(['localhost']); + const { req, res, next } = createMockReqResNext('http://evil.example.com'); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32_000 + }), + id: null + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + test('should allow an allowed Origin header (port-agnostic)', () => { + const middleware = originValidation(['localhost']); + const { req, res, next } = createMockReqResNext('http://localhost:3000'); + + middleware(req, res, next); + + expect(res.status).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('should allow requests without an Origin header (non-browser clients)', () => { + const middleware = originValidation(['localhost']); + const { req, res, next } = createMockReqResNext(undefined); + + middleware(req, res, next); + + expect(res.status).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('should deny on failure: malformed and null origins are rejected, never passed through', () => { + const middleware = originValidation(['localhost']); + for (const malformed of ['null', 'not a url']) { + const { req, res, next } = createMockReqResNext(malformed); + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + } + }); + + test('localhostOriginValidation allows the localhost family only', () => { + const middleware = localhostOriginValidation(); + + const allowed = createMockReqResNext('http://127.0.0.1:8080'); + middleware(allowed.req, allowed.res, allowed.next); + expect(allowed.next).toHaveBeenCalled(); + + const blocked = createMockReqResNext('http://localhost.evil.example.com'); + middleware(blocked.req, blocked.res, blocked.next); + expect(blocked.res.status).toHaveBeenCalledWith(403); + expect(blocked.next).not.toHaveBeenCalled(); + }); + }); + + describe('createMcpExpressApp origin arming', () => { + test('builds an app with default localhost origin protection', () => { + const app = createMcpExpressApp(); + expect(app).toBeDefined(); + expect(typeof app.use).toBe('function'); + }); + + test('arms localhost origin validation by default (requests are actually filtered)', async () => { + const app = createMcpExpressApp(); + app.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + + const blocked = await supertest(app).get('/health').set('Origin', 'http://evil.example.com'); + expect(blocked.status).toBe(403); + expect(blocked.body).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ code: -32_000 }), + id: null + }) + ); + + const allowed = await supertest(app).get('/health').set('Origin', 'http://localhost:5173'); + expect(allowed.status).toBe(200); + + const noOrigin = await supertest(app).get('/health'); + expect(noOrigin.status).toBe(200); + }); + + test('an explicit allowedOrigins list replaces the default allowlist (validation stays armed)', async () => { + const app = createMcpExpressApp({ + host: '0.0.0.0', + allowedHosts: ['127.0.0.1', 'myapp.local'], + allowedOrigins: ['myapp.local'] + }); + app.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + + const good = await supertest(app).get('/health').set('Origin', 'https://myapp.local'); + expect(good.status).toBe(200); + + const bad = await supertest(app).get('/health').set('Origin', 'http://localhost:5173'); + expect(bad.status).toBe(403); + }); + + test('applies no origin validation for 0.0.0.0 without allowedOrigins', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpExpressApp({ host: '0.0.0.0' }); + app.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + + const res = await supertest(app).get('/health').set('Origin', 'http://evil.example.com'); + expect(res.status).toBe(200); + warn.mockRestore(); + }); + + test('accepts an allowedOrigins override without warnings', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); + expect(app).toBeDefined(); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + test('keeps the existing 0.0.0.0 warning untouched when no allowlists are provided', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + createMcpExpressApp({ host: '0.0.0.0' }); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Warning: Server is binding to 0.0.0.0 without DNS rebinding protection') + ); + + warn.mockRestore(); + }); + }); +}); diff --git a/packages/middleware/fastify/src/fastify.ts b/packages/middleware/fastify/src/fastify.ts index 33c03dc808..cb5877c9a6 100644 --- a/packages/middleware/fastify/src/fastify.ts +++ b/packages/middleware/fastify/src/fastify.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import Fastify from 'fastify'; import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; +import { localhostOriginValidation, originValidation } from './middleware/originValidation.js'; /** * Options for creating an MCP Fastify application. @@ -22,6 +23,18 @@ export interface CreateMcpFastifyAppOptions { * to restrict which hostnames are allowed. */ allowedHosts?: string[]; + + /** + * List of allowed origin hostnames for Origin header validation. + * If provided, Origin validation will be applied using this list (port-agnostic, + * hostnames only — the same convention as `allowedHosts`). + * + * When omitted, Origin validation is automatically enabled for localhost-class + * binds (the same condition as host validation): requests without an `Origin` + * header pass, while a present `Origin` whose hostname is not localhost-class + * is rejected with `403`. + */ + allowedOrigins?: string[]; } /** @@ -54,7 +67,7 @@ export interface CreateMcpFastifyAppOptions { * ``` */ export function createMcpFastifyApp(options: CreateMcpFastifyAppOptions = {}): FastifyInstance { - const { host = '127.0.0.1', allowedHosts } = options; + const { host = '127.0.0.1', allowedHosts, allowedOrigins } = options; const app = Fastify(); @@ -78,5 +91,14 @@ export function createMcpFastifyApp(options: CreateMcpFastifyAppOptions = {}): F } } + // Origin validation follows the same arming ladder as host validation: + // an explicit allowlist wins; otherwise localhost-class binds are protected + // by default. Requests without an Origin header always pass. + if (allowedOrigins) { + app.addHook('onRequest', originValidation(allowedOrigins)); + } else if (['127.0.0.1', 'localhost', '::1'].includes(host)) { + app.addHook('onRequest', localhostOriginValidation()); + } + return app; } diff --git a/packages/middleware/fastify/src/index.ts b/packages/middleware/fastify/src/index.ts index 5c852617bb..61748e59a1 100644 --- a/packages/middleware/fastify/src/index.ts +++ b/packages/middleware/fastify/src/index.ts @@ -1,2 +1,3 @@ export * from './fastify.js'; export * from './middleware/hostHeaderValidation.js'; +export * from './middleware/originValidation.js'; diff --git a/packages/middleware/fastify/src/middleware/originValidation.ts b/packages/middleware/fastify/src/middleware/originValidation.ts new file mode 100644 index 0000000000..aad855885c --- /dev/null +++ b/packages/middleware/fastify/src/middleware/originValidation.ts @@ -0,0 +1,50 @@ +import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +/** + * Fastify onRequest hook for Origin header validation. + * Validates the `Origin` header hostname (port-agnostic) against an allowed list. + * + * Browsers attach an `Origin` header to cross-origin requests; validating it — + * alongside Host header validation — protects localhost and development servers + * against DNS rebinding and cross-site request forgery. Requests without an + * `Origin` header pass (non-browser MCP clients do not send one); a present + * value that is not allowed, or that cannot be parsed, is rejected with `403`. + * + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port). + * For IPv6, provide the address with brackets (e.g., `[::1]`). + * @returns Fastify onRequest hook handler + * + * @example + * ```ts + * app.addHook('onRequest', originValidation(['localhost', '127.0.0.1', '[::1]'])); + * ``` + */ +export function originValidation(allowedOriginHostnames: string[]) { + return async (request: FastifyRequest, reply: FastifyReply): Promise => { + const result = validateOriginHeader(request.headers.origin, allowedOriginHostnames); + if (!result.ok) { + await reply.code(403).send({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }); + } + }; +} + +/** + * Convenience hook for localhost Origin validation. + * Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost). + * + * @example + * ```ts + * app.addHook('onRequest', localhostOriginValidation()); + * ``` + */ +export function localhostOriginValidation() { + return originValidation(localhostAllowedOrigins()); +} diff --git a/packages/middleware/fastify/test/originValidation.test.ts b/packages/middleware/fastify/test/originValidation.test.ts new file mode 100644 index 0000000000..14dfc42beb --- /dev/null +++ b/packages/middleware/fastify/test/originValidation.test.ts @@ -0,0 +1,116 @@ +import Fastify from 'fastify'; + +import { createMcpFastifyApp } from '../src/fastify.js'; +import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation.js'; + +describe('@modelcontextprotocol/fastify origin validation', () => { + describe('originValidation', () => { + test('should block a disallowed Origin header', async () => { + const app = Fastify(); + app.addHook('onRequest', originValidation(['localhost'])); + app.get('/health', async () => ({ ok: true })); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000', origin: 'http://evil.example.com' } + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32_000 + }), + id: null + }) + ); + }); + + test('should allow an allowed Origin header and requests without an Origin header', async () => { + const app = Fastify(); + app.addHook('onRequest', localhostOriginValidation()); + app.get('/health', async () => 'ok'); + + const allowed = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000', origin: 'http://localhost:5173' } + }); + expect(allowed.statusCode).toBe(200); + + const noOrigin = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000' } + }); + expect(noOrigin.statusCode).toBe(200); + }); + + test('should deny malformed Origin values (deny on failure)', async () => { + const app = Fastify(); + app.addHook('onRequest', localhostOriginValidation()); + app.get('/health', async () => 'ok'); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000', origin: 'null' } + }); + expect(res.statusCode).toBe(403); + }); + }); + + describe('createMcpFastifyApp origin arming', () => { + test('arms localhost origin validation by default', async () => { + const app = createMcpFastifyApp(); + app.get('/health', async () => 'ok'); + + const bad = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000', origin: 'http://evil.example.com' } + }); + expect(bad.statusCode).toBe(403); + + const good = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000', origin: 'http://localhost:5173' } + }); + expect(good.statusCode).toBe(200); + }); + + test('uses allowedOrigins when provided', async () => { + const app = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); + app.get('/health', async () => 'ok'); + + const good = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'myapp.local:3000', origin: 'https://myapp.local' } + }); + expect(good.statusCode).toBe(200); + + const bad = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'myapp.local:3000', origin: 'http://evil.example.com' } + }); + expect(bad.statusCode).toBe(403); + }); + + test('applies no origin validation for 0.0.0.0 without allowedOrigins', async () => { + const app = createMcpFastifyApp({ host: '0.0.0.0' }); + app.get('/health', async () => 'ok'); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'whatever.example.com', origin: 'http://evil.example.com' } + }); + expect(res.statusCode).toBe(200); + }); + }); +}); diff --git a/packages/middleware/hono/src/hono.ts b/packages/middleware/hono/src/hono.ts index eda3e5d8fa..7d5405ce99 100644 --- a/packages/middleware/hono/src/hono.ts +++ b/packages/middleware/hono/src/hono.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono'; import { Hono } from 'hono'; import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; +import { localhostOriginValidation, originValidation } from './middleware/originValidation.js'; /** * Options for creating an MCP Hono application. @@ -22,6 +23,18 @@ export interface CreateMcpHonoAppOptions { * to restrict which hostnames are allowed. */ allowedHosts?: string[]; + + /** + * List of allowed origin hostnames for Origin header validation. + * If provided, Origin validation will be applied using this list (port-agnostic, + * hostnames only — the same convention as `allowedHosts`). + * + * When omitted, Origin validation is automatically enabled for localhost-class + * binds (the same condition as host validation): requests without an `Origin` + * header pass, while a present `Origin` whose hostname is not localhost-class + * is rejected with `403`. + */ + allowedOrigins?: string[]; } /** @@ -39,7 +52,7 @@ export interface CreateMcpHonoAppOptions { * @returns A configured Hono application */ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono { - const { host = '127.0.0.1', allowedHosts } = options; + const { host = '127.0.0.1', allowedHosts, allowedOrigins } = options; const app = new Hono(); @@ -86,5 +99,14 @@ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono { } } + // Origin validation follows the same arming ladder as host validation: + // an explicit allowlist wins; otherwise localhost-class binds are protected + // by default. Requests without an Origin header always pass. + if (allowedOrigins) { + app.use('*', originValidation(allowedOrigins)); + } else if (['127.0.0.1', 'localhost', '::1'].includes(host)) { + app.use('*', localhostOriginValidation()); + } + return app; } diff --git a/packages/middleware/hono/src/index.ts b/packages/middleware/hono/src/index.ts index a8c65a2e98..177b54d5b3 100644 --- a/packages/middleware/hono/src/index.ts +++ b/packages/middleware/hono/src/index.ts @@ -1,2 +1,3 @@ export * from './hono.js'; export * from './middleware/hostHeaderValidation.js'; +export * from './middleware/originValidation.js'; diff --git a/packages/middleware/hono/src/middleware/originValidation.ts b/packages/middleware/hono/src/middleware/originValidation.ts new file mode 100644 index 0000000000..f75076c2be --- /dev/null +++ b/packages/middleware/hono/src/middleware/originValidation.ts @@ -0,0 +1,38 @@ +import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server'; +import type { MiddlewareHandler } from 'hono'; + +/** + * Hono middleware for Origin header validation. + * Validates the `Origin` header hostname (port-agnostic) against an allowed list. + * + * Requests without an `Origin` header pass (non-browser MCP clients do not send + * one); a present value that is not allowed, or that cannot be parsed, is + * rejected with `403`. + */ +export function originValidation(allowedOriginHostnames: string[]): MiddlewareHandler { + return async (c, next) => { + const result = validateOriginHeader(c.req.header('origin'), allowedOriginHostnames); + if (!result.ok) { + return c.json( + { + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }, + 403 + ); + } + return await next(); + }; +} + +/** + * Convenience middleware for localhost Origin validation. + * Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost). + */ +export function localhostOriginValidation(): MiddlewareHandler { + return originValidation(localhostAllowedOrigins()); +} diff --git a/packages/middleware/hono/test/originValidation.test.ts b/packages/middleware/hono/test/originValidation.test.ts new file mode 100644 index 0000000000..c395921d39 --- /dev/null +++ b/packages/middleware/hono/test/originValidation.test.ts @@ -0,0 +1,91 @@ +import { Hono } from 'hono'; +import { vi } from 'vitest'; + +import { createMcpHonoApp } from '../src/hono.js'; +import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation.js'; + +describe('@modelcontextprotocol/hono origin validation', () => { + test('originValidation blocks a disallowed Origin and allows an allowed Origin', async () => { + const app = new Hono(); + app.use('*', originValidation(['localhost'])); + app.get('/health', c => c.text('ok')); + + const bad = await app.request('http://localhost/health', { + headers: { Host: 'localhost:3000', Origin: 'http://evil.example.com' } + }); + expect(bad.status).toBe(403); + expect(await bad.json()).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32_000 + }), + id: null + }) + ); + + const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000', Origin: 'http://localhost:3000' } }); + expect(good.status).toBe(200); + expect(await good.text()).toBe('ok'); + }); + + test('originValidation allows requests without an Origin header and denies malformed origins', async () => { + const app = new Hono(); + app.use('*', localhostOriginValidation()); + app.get('/health', c => c.text('ok')); + + const noOrigin = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } }); + expect(noOrigin.status).toBe(200); + + const malformed = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000', Origin: 'null' } }); + expect(malformed.status).toBe(403); + }); + + test('createMcpHonoApp arms localhost origin validation by default', async () => { + const app = createMcpHonoApp(); + app.get('/health', c => c.text('ok')); + + const bad = await app.request('http://localhost/health', { + headers: { Host: 'localhost:3000', Origin: 'http://evil.example.com' } + }); + expect(bad.status).toBe(403); + + const goodOrigin = await app.request('http://localhost/health', { + headers: { Host: 'localhost:3000', Origin: 'http://localhost:5173' } + }); + expect(goodOrigin.status).toBe(200); + + const noOrigin = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } }); + expect(noOrigin.status).toBe(200); + }); + + test('createMcpHonoApp uses allowedOrigins when provided', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); + warn.mockRestore(); + app.get('/health', c => c.text('ok')); + + const good = await app.request('http://localhost/health', { + headers: { Host: 'myapp.local:3000', Origin: 'https://myapp.local' } + }); + expect(good.status).toBe(200); + + const bad = await app.request('http://localhost/health', { + headers: { Host: 'myapp.local:3000', Origin: 'http://evil.example.com' } + }); + expect(bad.status).toBe(403); + }); + + test('createMcpHonoApp applies no origin validation for 0.0.0.0 without allowedOrigins (existing warning preserved)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpHonoApp({ host: '0.0.0.0' }); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + app.get('/health', c => c.text('ok')); + + const anyOrigin = await app.request('http://localhost/health', { + headers: { Host: 'whatever.example.com', Origin: 'http://evil.example.com' } + }); + expect(anyOrigin.status).toBe(200); + }); +}); diff --git a/packages/middleware/node/src/index.ts b/packages/middleware/node/src/index.ts index 2e0d3c9950..8426de0757 100644 --- a/packages/middleware/node/src/index.ts +++ b/packages/middleware/node/src/index.ts @@ -1 +1,3 @@ +export * from './middleware/hostHeaderValidation.js'; +export * from './middleware/originValidation.js'; export * from './streamableHttp.js'; diff --git a/packages/middleware/node/src/middleware/hostHeaderValidation.ts b/packages/middleware/node/src/middleware/hostHeaderValidation.ts new file mode 100644 index 0000000000..4630c27669 --- /dev/null +++ b/packages/middleware/node/src/middleware/hostHeaderValidation.ts @@ -0,0 +1,53 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server'; + +/** + * Node.js request guard for DNS rebinding protection. + * Validates the `Host` header hostname (port-agnostic) against an allowed list. + * + * Unlike the framework adapters, plain `node:http` has no middleware chain, so + * the guard returns whether the request may proceed: when it returns `false` + * it has already answered the request with a `403` JSON-RPC error and the + * caller must not handle it further. + * + * @param allowedHostnames - List of allowed hostnames (without ports). + * For IPv6, provide the address with brackets (e.g., `[::1]`). + * + * @example + * ```ts + * const validateHost = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); + * http.createServer((req, res) => { + * if (!validateHost(req, res)) return; + * void transport.handleRequest(req, res); + * }); + * ``` + */ +export function hostHeaderValidation(allowedHostnames: string[]): (req: IncomingMessage, res: ServerResponse) => boolean { + return (req, res) => { + const result = validateHostHeader(req.headers.host, allowedHostnames); + if (result.ok) { + return true; + } + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }) + ); + return false; + }; +} + +/** + * Convenience guard for localhost DNS rebinding protection. + * Allows only `localhost`, `127.0.0.1`, and `[::1]` (IPv6 localhost) hostnames. + */ +export function localhostHostValidation(): (req: IncomingMessage, res: ServerResponse) => boolean { + return hostHeaderValidation(localhostAllowedHostnames()); +} diff --git a/packages/middleware/node/src/middleware/originValidation.ts b/packages/middleware/node/src/middleware/originValidation.ts new file mode 100644 index 0000000000..a38fc05144 --- /dev/null +++ b/packages/middleware/node/src/middleware/originValidation.ts @@ -0,0 +1,54 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server'; + +/** + * Node.js request guard for Origin header validation. + * Validates the `Origin` header hostname (port-agnostic) against an allowed list. + * + * Requests without an `Origin` header pass (non-browser MCP clients do not send + * one); a present value that is not allowed, or that cannot be parsed, is + * rejected with `403`. The guard returns whether the request may proceed: when + * it returns `false` it has already answered the request and the caller must + * not handle it further. + * + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port). + * For IPv6, provide the address with brackets (e.g., `[::1]`). + * + * @example + * ```ts + * const validateOrigin = originValidation(['localhost', '127.0.0.1', '[::1]']); + * http.createServer((req, res) => { + * if (!validateOrigin(req, res)) return; + * void transport.handleRequest(req, res); + * }); + * ``` + */ +export function originValidation(allowedOriginHostnames: string[]): (req: IncomingMessage, res: ServerResponse) => boolean { + return (req, res) => { + const result = validateOriginHeader(req.headers.origin, allowedOriginHostnames); + if (result.ok) { + return true; + } + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }) + ); + return false; + }; +} + +/** + * Convenience guard for localhost Origin validation. + * Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost). + */ +export function localhostOriginValidation(): (req: IncomingMessage, res: ServerResponse) => boolean { + return originValidation(localhostAllowedOrigins()); +} diff --git a/packages/middleware/node/test/validation.test.ts b/packages/middleware/node/test/validation.test.ts new file mode 100644 index 0000000000..01e98108e0 --- /dev/null +++ b/packages/middleware/node/test/validation.test.ts @@ -0,0 +1,79 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { vi } from 'vitest'; + +import { hostHeaderValidation, localhostHostValidation } from '../src/middleware/hostHeaderValidation.js'; +import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation.js'; + +function fakeReqRes(headers: Record) { + const req = { headers } as unknown as IncomingMessage; + const writeHead = vi.fn().mockReturnThis(); + const end = vi.fn().mockReturnThis(); + const res = { writeHead, end } as unknown as ServerResponse; + return { req, res, writeHead, end }; +} + +function sentBody(end: ReturnType): unknown { + const payload = end.mock.calls[0]?.[0] as string | undefined; + return payload === undefined ? undefined : JSON.parse(payload); +} + +describe('@modelcontextprotocol/node validation guards', () => { + describe('hostHeaderValidation', () => { + test('blocks a disallowed Host header with a 403 JSON-RPC error and reports the request as handled', () => { + const guard = hostHeaderValidation(['localhost']); + const { req, res, writeHead, end } = fakeReqRes({ host: 'evil.example.com:3000' }); + + expect(guard(req, res)).toBe(false); + expect(writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'application/json' }); + expect(sentBody(end)).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ code: -32_000 }), + id: null + }) + ); + }); + + test('allows an allowed Host header (port-agnostic)', () => { + const guard = localhostHostValidation(); + const { req, res, writeHead } = fakeReqRes({ host: '127.0.0.1:8080' }); + + expect(guard(req, res)).toBe(true); + expect(writeHead).not.toHaveBeenCalled(); + }); + }); + + describe('originValidation', () => { + test('blocks a disallowed Origin header with a 403 JSON-RPC error', () => { + const guard = originValidation(['localhost']); + const { req, res, writeHead, end } = fakeReqRes({ host: 'localhost:3000', origin: 'http://evil.example.com' }); + + expect(guard(req, res)).toBe(false); + expect(writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'application/json' }); + expect(sentBody(end)).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ code: -32_000 }), + id: null + }) + ); + }); + + test('allows an allowed Origin and requests without an Origin header', () => { + const guard = localhostOriginValidation(); + + const allowed = fakeReqRes({ host: 'localhost:3000', origin: 'http://localhost:5173' }); + expect(guard(allowed.req, allowed.res)).toBe(true); + + const absent = fakeReqRes({ host: 'localhost:3000' }); + expect(guard(absent.req, absent.res)).toBe(true); + }); + + test('denies malformed Origin values (deny on failure)', () => { + const guard = localhostOriginValidation(); + const { req, res } = fakeReqRes({ host: 'localhost:3000', origin: 'null' }); + expect(guard(req, res)).toBe(false); + }); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c33d394c8b..2a1e272d43 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,17 @@ export type { CompletableSchema, CompleteCallback } from './server/completable.js'; export { completable, isCompletable } from './server/completable.js'; +export type { + CreateMcpHandlerOptions, + LegacyHttpHandler, + McpHandlerRequestOptions, + McpHttpHandler, + McpRequestContext, + McpServerFactory, + NodeIncomingMessageLike, + NodeServerResponseLike +} from './server/createMcpHandler.js'; +export { createMcpHandler, legacyStatelessFallback } from './server/createMcpHandler.js'; export type { AnyToolHandler, BaseToolCallback, @@ -26,6 +37,10 @@ export type { export { McpServer, ResourceTemplate } from './server/mcp.js'; export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation.js'; export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js'; +export type { OriginValidationResult } from './server/middleware/originValidation.js'; +export { localhostAllowedOrigins, originValidationResponse, validateOriginHeader } from './server/middleware/originValidation.js'; +export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerRequestResponseMode } from './server/perRequestTransport.js'; +export { PerRequestHTTPServerTransport } from './server/perRequestTransport.js'; export type { ServerOptions } from './server/server.js'; export { Server } from './server/server.js'; // StdioServerTransport is exported from the './stdio' subpath — server stdio has only type-level Node @@ -43,5 +58,18 @@ export { WebStandardStreamableHTTPServerTransport } from './server/streamableHtt // runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) export { fromJsonSchema } from './fromJsonSchema.js'; +// Inbound HTTP request classification (dual-era serving): the body-primary era +// predicate used by createMcpHandler, exported for hand-wired compositions. +export type { + InboundClassificationOutcome, + InboundHttpRequest, + InboundLadderRejection, + InboundLegacyRoute, + InboundLegacyRouteReason, + InboundModernRoute, + InboundValidationRung +} from '@modelcontextprotocol/core'; +export { classifyInboundRequest } from '@modelcontextprotocol/core'; + // re-export curated public API from core export * from '@modelcontextprotocol/core/public'; diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts new file mode 100644 index 0000000000..a3341c5fed --- /dev/null +++ b/packages/server/src/server/createMcpHandler.ts @@ -0,0 +1,760 @@ +/** + * `createMcpHandler` — the HTTP entry point for serving the 2026-07-28 protocol + * revision, with 2025-era serving available as an opt-in slot. + * + * The entry classifies every inbound HTTP request exactly once (body-primary, + * via {@linkcode classifyInboundRequest}) and routes it: + * + * - Requests carrying the per-request `_meta` envelope are served on the modern + * path: a fresh server instance from the consumer's factory, marked as + * serving the claimed revision, connected to a single-exchange per-request + * transport. + * - Requests without an envelope claim (including `initialize`, GET/DELETE + * session operations, and 2025-era notification POSTs) are legacy traffic. + * When the `legacy` slot is configured they are handed to it untouched; when + * it is not, the endpoint is modern-only strict and answers the documented + * rejection cells. There is no silent 2025 serving without the slot. + * + * The entry performs no Origin/Host validation (mount the origin/host + * validation middleware in front of it) and no token verification — `authInfo` + * is pass-through from the caller and is never derived from request headers. + */ +import type { + AuthInfo, + ClientCapabilities, + Implementation, + InboundLadderRejection, + InboundLegacyRoute, + InboundModernRoute, + JSONRPCNotification, + JSONRPCRequest, + RequestId +} from '@modelcontextprotocol/core'; +import { + classifyInboundRequest, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + modernOnlyStrictRejection, + requestMetaOf, + SdkError, + SdkErrorCode, + setNegotiatedProtocolVersion, + SUPPORTED_MODERN_PROTOCOL_VERSIONS, + UnsupportedProtocolVersionError +} from '@modelcontextprotocol/core'; + +import { invoke } from './invoke.js'; +import { McpServer } from './mcp.js'; +import type { PerRequestResponseMode } from './perRequestTransport.js'; +import type { Server } from './server.js'; +import { installModernOnlyHandlers, seedClientIdentityFromEnvelope } from './server.js'; +import { WebStandardStreamableHTTPServerTransport } from './streamableHttp.js'; + +/* ------------------------------------------------------------------------ * + * Factory and handler types + * ------------------------------------------------------------------------ */ + +/** + * Per-request construction context handed to an {@linkcode McpServerFactory}. + * + * Zero-argument factories remain assignable unchanged; the context exists for + * factories that vary by principal or era (for example multi-tenant servers + * keyed off `authInfo`, or a factory that registers extra surface only for one + * era). + */ +export interface McpRequestContext { + /** + * The protocol era of the request the constructed instance will serve: + * `modern` for 2026-07-28 (per-request envelope) traffic, `legacy` for + * 2025-era traffic served through the `legacy: 'stateless'` slot. + */ + era: 'legacy' | 'modern'; + /** Validated authentication information passed by the caller of the handler face (pass-through). */ + authInfo?: AuthInfo; + /** The original HTTP request being served, when available. */ + requestInfo?: Request; +} + +/** + * A factory producing a fresh {@linkcode McpServer} (or low-level + * {@linkcode Server}) instance for one request. The same factory backs both + * the modern path and the `legacy: 'stateless'` slot — define your tools, + * resources and prompts once and serve them to both eras. + */ +export type McpServerFactory = (ctx: McpRequestContext) => McpServer | Server | Promise; + +/** Caller-provided per-request inputs for {@linkcode McpHttpHandler.fetch} and legacy slot handlers. */ +export interface McpHandlerRequestOptions { + /** + * Validated authentication information for the request. Strictly + * pass-through: the handler never populates this from request headers and + * performs no token verification of its own. + */ + authInfo?: AuthInfo; + /** A pre-parsed JSON request body (e.g. `req.body` from `express.json()`). */ + parsedBody?: unknown; +} + +/** + * A fetch-shaped handler serving 2025-era traffic for the `legacy` slot: + * receives the original request untouched (plus the caller-provided + * pass-through options) and produces the HTTP response. + */ +export type LegacyHttpHandler = (request: Request, options?: McpHandlerRequestOptions) => Promise; + +/** Options for {@linkcode createMcpHandler}. */ +export interface CreateMcpHandlerOptions { + /** + * How 2025-era (non-envelope) traffic is served: + * + * - omitted — modern-only strict: legacy-classified requests are rejected + * with the unsupported-protocol-version error naming the endpoint's + * supported revisions (legacy-classified notifications are acknowledged + * with `202` and dropped). **There is no silent 2025 serving.** + * - `'stateless'` — serve legacy traffic with the per-request stateless + * idiom (a fresh instance from the same factory and a streamable HTTP + * transport constructed with only `sessionIdGenerator: undefined`). + * Equivalent to passing {@linkcode legacyStatelessFallback | legacyStatelessFallback(factory)}. + * - a handler — bring your own legacy serving (for example an existing + * sessionful streamable HTTP wiring); requests are handed to it + * byte-untouched and its lifecycle stays yours. + */ + legacy?: 'stateless' | LegacyHttpHandler; + /** Callback for out-of-band errors and rejected requests (reporting only; it never alters the response). */ + onerror?: (error: Error) => void; + /** + * Response shaping for modern (2026-07-28) request exchanges: + * + * - `'auto'` (default) — a single JSON body unless the handler emits a + * related message before its result, in which case the response upgrades + * to an SSE stream. + * - `'sse'` — always stream. + * - `'json'` — never stream. **Mid-call notifications (progress, logging, + * any related message emitted before the result) are dropped** — only the + * terminal result is delivered. Listen-class subscription streams are + * always served over SSE regardless of this setting. + */ + responseMode?: PerRequestResponseMode; +} + +/** + * Minimal duck-typed shape of a Node.js `IncomingMessage` accepted by + * {@linkcode McpHttpHandler.node}. Kept structural so the handler stays free of + * `node:` imports and bundles for non-Node runtimes. + */ +export interface NodeIncomingMessageLike extends AsyncIterable { + method?: string; + url?: string; + headers: Record; + /** Validated authentication info attached by upstream middleware (pass-through). */ + auth?: AuthInfo; +} + +/** Minimal duck-typed shape of a Node.js `ServerResponse` accepted by {@linkcode McpHttpHandler.node}. */ +export interface NodeServerResponseLike { + writeHead(statusCode: number, headers?: Record): unknown; + write(chunk: string | Uint8Array): unknown; + end(chunk?: string | Uint8Array): unknown; + on(event: string, listener: (...args: unknown[]) => void): unknown; +} + +/** + * The handler returned by {@linkcode createMcpHandler}. Both faces are + * arrow-assigned bound properties: they can be detached and passed around + * (`const { fetch } = handler`) without losing their binding. + */ +export interface McpHttpHandler { + /** Web-standard face: serve one HTTP request and resolve with the response. */ + fetch: (request: Request, options?: McpHandlerRequestOptions) => Promise; + /** + * Node face: serve one Node.js request/response pair. The third argument is + * an optional pre-parsed body (`req.body` from `express.json()`); a function + * third argument (Express's `next` when the handler is mounted as + * middleware) is ignored. + */ + node: (req: NodeIncomingMessageLike, res: NodeServerResponseLike, parsedBody?: unknown) => Promise; + /** + * Tears down the modern leg: aborts in-flight modern exchanges and closes + * their per-request instances. Legacy serving is unaffected — the + * `'stateless'` slot is per-request by construction, and a bring-your-own + * legacy handler's lifecycle stays with its owner. + */ + close: () => Promise; +} + +/* ------------------------------------------------------------------------ * + * Shared response helpers + * ------------------------------------------------------------------------ */ + +/** + * The JSON-RPC id to echo on an entry-built error response: the body's `id` + * when the body is a single JSON-RPC request whose id is a string or number, + * `null` otherwise. Error responses must carry the id of the request they + * correspond to whenever it could be read; `null` is reserved for the cases + * where no single request id is determinable — unparseable bodies, body-less + * methods, notifications, posted responses and batch arrays. + */ +function echoableRequestId(body: unknown): RequestId | null { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return null; + } + const { method, id } = body as { method?: unknown; id?: unknown }; + if (typeof method !== 'string') { + return null; + } + return typeof id === 'string' || typeof id === 'number' ? id : null; +} + +function jsonRpcErrorResponse(httpStatus: number, code: number, message: string, data?: unknown, id: RequestId | null = null): Response { + return Response.json( + { + jsonrpc: '2.0', + error: { code, message, ...(data !== undefined && { data }) }, + id + }, + { status: httpStatus } + ); +} + +function rejectionResponse(rejection: InboundLadderRejection, id: RequestId | null = null): Response { + return jsonRpcErrorResponse(rejection.httpStatus, rejection.code, rejection.message, rejection.data, id); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +/** + * Whether the given factory product has the (forthcoming) subscriptions feature + * configured. The subscriptions registry does not exist yet, so this currently + * always reports `false`; the subscriptions feature replaces this predicate + * when it lands, which arms the `responseMode: 'json'` startup warning below. + */ +function hasConfiguredSubscriptions(_product: McpServer | Server): boolean { + return false; +} + +function internalServerErrorResponse(id: RequestId | null = null): Response { + return jsonRpcErrorResponse(500, -32_603, 'Internal server error', undefined, id); +} + +/* ------------------------------------------------------------------------ * + * The canonical legacy slot value + * ------------------------------------------------------------------------ */ + +/** + * The canonical `legacy` slot value: per-request stateless serving of 2025-era + * traffic using the same factory as the modern path. + * + * Each POST is served by a fresh instance from the factory connected to a + * fresh streamable HTTP transport constructed with only + * `sessionIdGenerator: undefined` — the established stateless idiom, unchanged. + * Because serving is per-request and stateless, GET and DELETE (2025 session + * operations) are answered with `405` / `Method not allowed.`, exactly like the + * canonical stateless example. `createMcpHandler(factory, { legacy: 'stateless' })` + * is shorthand for passing `legacyStatelessFallback(factory)` here explicitly. + * + * The optional `onerror` callback receives factory and serving failures on + * this leg (reporting only — the response stays the 500 internal-error body). + * The entry passes its own `onerror` here when expanding `legacy: 'stateless'`, + * so legacy-leg failures are never silently swallowed. + */ +export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler { + return async (request, options) => { + if (request.method.toUpperCase() !== 'POST') { + return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.'); + } + try { + const product = await factory({ + era: 'legacy', + ...(options?.authInfo !== undefined && { authInfo: options.authInfo }), + requestInfo: request + }); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await product.connect(transport); + + const teardown = () => { + void transport.close().catch(() => {}); + void product.close().catch(() => {}); + }; + // Tear the per-request pair down when the client goes away before + // the exchange completes. + request.signal?.addEventListener('abort', teardown, { once: true }); + + const response = await transport.handleRequest(request, { + ...(options?.authInfo !== undefined && { authInfo: options.authInfo }), + ...(options?.parsedBody !== undefined && { parsedBody: options.parsedBody }) + }); + if (response.body === null || !(response.headers.get('content-type') ?? '').includes('text/event-stream')) { + // Non-streaming exchange (a buffered JSON body or a body-less + // ack): the response is complete, release the pair now. + teardown(); + return response; + } + // Streaming exchange: the legacy transport answers request-bearing + // POSTs over SSE, so the exchange is only over once the stream has + // been fully delivered. Wrap the body so the pair is torn down on + // completion, on a producer error, or when the consumer abandons + // the stream — the fetch-world analog of the canonical stateless + // example's close-on-response-end. + const reader = response.body.getReader(); + let toreDown = false; + const completeExchange = () => { + if (!toreDown) { + toreDown = true; + teardown(); + } + }; + const monitoredBody = new ReadableStream({ + pull: async controller => { + try { + const { done, value } = await reader.read(); + if (done) { + completeExchange(); + controller.close(); + return; + } + if (value !== undefined) { + controller.enqueue(value); + } + } catch (error) { + completeExchange(); + controller.error(error); + } + }, + cancel: reason => { + completeExchange(); + return reader.cancel(reason).catch(() => {}); + } + }); + return new Response(monitoredBody, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + } catch (error) { + try { + onerror?.(toError(error)); + } catch { + // Reporting must never alter the response. + } + return internalServerErrorResponse(echoableRequestId(options?.parsedBody)); + } + }; +} + +/* ------------------------------------------------------------------------ * + * The entry + * ------------------------------------------------------------------------ */ + +/** + * Creates an HTTP handler that serves the 2026-07-28 protocol revision from a + * per-request server factory, with 2025-era serving available through the + * opt-in `legacy` slot. + * + * Mounting: `handler.fetch` is the web-standard face (Cloudflare Workers, + * Deno, Bun, Hono's `c.req.raw`); `handler.node(req, res, req.body)` is the + * Node face for Express/Fastify/plain `node:http`. When mounting bare on a + * fetch-native runtime, put Origin/Host validation in front of the handler — + * the entry itself is deliberately validation-free: + * + * ```ts + * import { hostHeaderValidationResponse, originValidationResponse, localhostAllowedHostnames, localhostAllowedOrigins } from '@modelcontextprotocol/server'; + * + * export default { + * async fetch(request: Request): Promise { + * const rejected = + * hostHeaderValidationResponse(request, localhostAllowedHostnames()) ?? + * originValidationResponse(request, localhostAllowedOrigins()); + * return rejected ?? handler.fetch(request); + * } + * }; + * ``` + * + * Use ONE factory for both legs: the same tools/resources/prompts definition + * backs the modern path and the `legacy: 'stateless'` slot, so the two eras + * can never drift apart. Power users who want to compose the routing + * themselves (for example to mount the modern path and an existing legacy + * deployment on different routes) can use the exported building blocks + * directly: {@linkcode classifyInboundRequest} for the era decision and + * `PerRequestHTTPServerTransport` for single-exchange serving. + * + * The entry performs no token verification: `authInfo` given to the faces is + * passed through to handlers and the factory as-is and is never derived from + * request headers. + */ +export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHandlerOptions = {}): McpHttpHandler { + const { legacy, onerror, responseMode } = options; + + /** Modern per-request instances with an exchange still in flight (close() tears these down). */ + const inflight = new Set(); + let closed = false; + let warnedJsonModeSubscriptions = false; + + const reportError = (error: Error) => { + try { + onerror?.(error); + } catch { + // Reporting must never alter the response. + } + }; + + const legacyHandler: LegacyHttpHandler | undefined = legacy === 'stateless' ? legacyStatelessFallback(factory, reportError) : legacy; + + async function serveModern( + route: InboundModernRoute, + message: JSONRPCRequest | JSONRPCNotification, + request: Request, + authInfo: AuthInfo | undefined + ): Promise { + const claimedRevision = route.classification.revision; + if (claimedRevision === undefined || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedRevision)) { + // The claim names a revision this endpoint does not serve (an + // unknown future revision, or a 2025-era revision delivered via the + // envelope mechanism). + const error = new UnsupportedProtocolVersionError({ + supported: [...SUPPORTED_MODERN_PROTOCOL_VERSIONS], + requested: claimedRevision ?? 'unknown' + }); + reportError(error); + return jsonRpcErrorResponse(400, error.code, error.message, error.data, echoableRequestId(message)); + } + + const product = await factory({ + era: 'modern', + ...(authInfo !== undefined && { authInfo }), + requestInfo: request + }); + const server = product instanceof McpServer ? product.server : product; + + // Era-write at instance binding, then modern-only handler installation — + // both before the instance is connected to the per-request transport. + setNegotiatedProtocolVersion(server, claimedRevision); + installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS); + + if (route.messageKind === 'request') { + const meta = requestMetaOf((message as JSONRPCRequest).params); + if (meta !== undefined) { + seedClientIdentityFromEnvelope(server, { + clientInfo: meta[CLIENT_INFO_META_KEY] as Implementation | undefined, + clientCapabilities: meta[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined + }); + } + } + + if (responseMode === 'json' && !warnedJsonModeSubscriptions && hasConfiguredSubscriptions(product)) { + warnedJsonModeSubscriptions = true; + // eslint-disable-next-line no-console + console.warn( + "Warning: responseMode: 'json' drops mid-call notifications, but this server configures subscriptions. " + + 'Subscription (listen) streams are always served over SSE; other notifications emitted before a result will be dropped.' + ); + } + + // Track the instance until its exchange tears down so close() can abort it. + const previousOnClose = server.onclose; + inflight.add(server); + server.onclose = () => { + inflight.delete(server); + previousOnClose?.(); + }; + + // Listen-class streams are always SSE: even under 'json', a listen + // request's per-request transport keeps the lazy upgrade available. + const effectiveResponseMode: PerRequestResponseMode | undefined = + responseMode === 'json' && route.messageKind === 'request' && (message as JSONRPCRequest).method === 'subscriptions/listen' + ? 'auto' + : responseMode; + + try { + const response = await invoke(product, message, { + classification: route.classification, + request, + ...(authInfo !== undefined && { authInfo }), + ...(effectiveResponseMode !== undefined && { responseMode: effectiveResponseMode }) + }); + if (route.messageKind === 'notification') { + // Notification exchanges have no terminal response to ride the + // transport's auto-close, so release the per-request instance here. + queueMicrotask(() => void server.close().catch(() => {})); + } + return response; + } catch (error) { + if (error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed) { + // The client went away before a response existed; there is + // nobody left to answer. + return new Response(null, { status: 499 }); + } + // No terminal response will ride the transport's close chain after a + // failure here: close the per-request instance explicitly and drop it + // from the in-flight set so repeated failures cannot accumulate + // connected instances until handler.close(). + await server.close().catch(() => {}); + inflight.delete(server); + reportError(toError(error)); + return internalServerErrorResponse(echoableRequestId(message)); + } + } + + async function serveLegacyRoute( + route: InboundLegacyRoute, + forwardRequest: Request, + authInfo: AuthInfo | undefined, + parsedBody: unknown + ): Promise { + if (legacyHandler !== undefined) { + return legacyHandler(forwardRequest, { + ...(authInfo !== undefined && { authInfo }), + ...(parsedBody !== undefined && { parsedBody }) + }); + } + const strict = modernOnlyStrictRejection(route, SUPPORTED_MODERN_PROTOCOL_VERSIONS); + if (strict === undefined) { + // Legacy-classified notification on a modern-only endpoint: + // acknowledged and dropped, never dispatched. + return new Response(null, { status: 202 }); + } + reportError(new Error(`Rejected 2025-era request on a modern-only endpoint (${strict.cell}): ${strict.message}`)); + return rejectionResponse(strict, echoableRequestId(parsedBody)); + } + + async function handle(request: Request, requestOptions?: McpHandlerRequestOptions): Promise { + const httpMethod = request.method.toUpperCase(); + const authInfo = requestOptions?.authInfo; + + let body: unknown; + let parsedBody = requestOptions?.parsedBody; + let forwardRequest = request; + let unparseable = false; + + if (httpMethod === 'POST') { + if (parsedBody === undefined) { + // Read the body exactly once for classification, keeping an + // unread copy of the original bytes for the legacy slot + // (web-standard request bodies are single-use). + forwardRequest = request.clone(); + let bodyText: string; + try { + bodyText = await request.text(); + } catch { + return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body could not be read'); + } + try { + body = bodyText.length === 0 ? undefined : JSON.parse(bodyText); + } catch { + unparseable = true; + } + if (!unparseable && body !== undefined) { + parsedBody = body; + } + } else { + body = parsedBody; + } + + if (unparseable || body === undefined) { + // No JSON body to classify: there is no envelope claim, so this + // is legacy traffic when a slot is configured (the legacy leg + // answers its own parse error, unchanged), and a parse error + // otherwise. + if (legacyHandler !== undefined) { + return legacyHandler(forwardRequest, { ...(authInfo !== undefined && { authInfo }) }); + } + return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body is not valid JSON'); + } + } + + const outcome = classifyInboundRequest({ + httpMethod, + protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + ...(body !== undefined && { body }) + }); + + try { + switch (outcome.kind) { + case 'reject': { + reportError(new Error(`Rejected inbound request (${outcome.cell}): ${outcome.message}`)); + return rejectionResponse(outcome, echoableRequestId(body)); + } + case 'modern': { + return await serveModern(outcome, body as JSONRPCRequest | JSONRPCNotification, request, authInfo); + } + case 'legacy': { + return await serveLegacyRoute(outcome, forwardRequest, authInfo, parsedBody); + } + } + } catch (error) { + // Entry-internal failure while serving a classified request (a + // throwing factory, a failed connect, a throwing bring-your-own + // legacy handler): the parsed body is in scope here, so the 500 + // body echoes the request id when it could be read. + reportError(toError(error)); + return internalServerErrorResponse(echoableRequestId(body)); + } + } + + const fetchFace = async (request: Request, requestOptions?: McpHandlerRequestOptions): Promise => { + if (closed) { + throw new Error('This MCP handler has been closed'); + } + try { + return await handle(request, requestOptions); + } catch (error) { + reportError(toError(error)); + return internalServerErrorResponse(echoableRequestId(requestOptions?.parsedBody)); + } + }; + + const nodeFace = async (req: NodeIncomingMessageLike, res: NodeServerResponseLike, parsedBody?: unknown): Promise => { + // Express passes (req, res, next) when the handler is mounted as a + // middleware function; a function third argument is `next`, not a body. + if (typeof parsedBody === 'function') { + parsedBody = undefined; + } + + let finished = false; + const abort = new AbortController(); + res.on('close', () => { + if (!finished) { + abort.abort(); + } + }); + + let response: Response; + try { + const request = await nodeRequestToFetchRequest(req, parsedBody, abort.signal); + response = await fetchFace(request, { + ...(req.auth !== undefined && { authInfo: req.auth }), + ...(parsedBody !== undefined && { parsedBody }) + }); + } catch (error) { + reportError(toError(error)); + response = internalServerErrorResponse(echoableRequestId(parsedBody)); + } + + const headers: Record = {}; + for (const [name, value] of response.headers) { + headers[name] = value; + } + res.writeHead(response.status, headers); + if (response.body === null) { + finished = true; + res.end(); + return; + } + const reader = response.body.getReader(); + // Honor write backpressure: when write() reports a full buffer (Node's + // `false` return), wait for the response to drain before pulling the + // next chunk. A single listener resolves whichever wait is pending; a + // closed response also releases the wait so a vanished client cannot + // park the loop forever. + let drainResolve: (() => void) | undefined; + const releaseDrainWait = () => { + drainResolve?.(); + drainResolve = undefined; + }; + res.on('drain', releaseDrainWait); + res.on('close', releaseDrainWait); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value !== undefined && res.write(value) === false) { + await new Promise(resolve => { + drainResolve = resolve; + }); + } + } + } catch { + // The client went away while streaming; the abort signal already + // cancelled the exchange. + } + finished = true; + res.end(); + }; + + return { + fetch: fetchFace, + node: nodeFace, + close: async () => { + closed = true; + const closing = [...inflight].map(server => server.close().catch(() => {})); + inflight.clear(); + await Promise.all(closing); + } + }; +} + +/* ------------------------------------------------------------------------ * + * Node request conversion (duck-typed; no node: imports) + * ------------------------------------------------------------------------ */ + +function singleHeaderValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +async function nodeRequestToFetchRequest(req: NodeIncomingMessageLike, parsedBody: unknown, signal: AbortSignal): Promise { + const method = (req.method ?? 'GET').toUpperCase(); + const host = singleHeaderValue(req.headers['host']) ?? 'localhost'; + const url = `http://${host}${req.url ?? '/'}`; + + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + // HTTP/2 pseudo-headers (`:method`, `:path`, `:authority`, …) are + // connection metadata, not header fields — `Headers` rejects their + // names, so they are skipped rather than copied. + if (value === undefined || name.startsWith(':')) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + headers.append(name, item); + } + } else { + headers.set(name, value); + } + } + + // The body is carried as text: MCP request bodies are JSON, and a string + // body keeps the constructed Request portable across runtime lib versions. + let body: string | undefined; + if (method !== 'GET' && method !== 'HEAD') { + if (parsedBody === undefined) { + const decoder = new TextDecoder(); + let collected = ''; + for await (const chunk of req) { + collected += typeof chunk === 'string' ? chunk : decoder.decode(chunk as Uint8Array, { stream: true }); + } + collected += decoder.decode(); + if (collected.length > 0) { + body = collected; + } + } else { + // The caller already consumed and parsed the Node stream (the + // documented `handler.node(req, res, req.body)` mounting behind + // `express.json()`), so the bytes cannot be re-read. Re-serialize + // the parsed value so consumers of the forwarded Request — a + // bring-your-own legacy handler reading `request.json()`/`text()` + // in particular — still receive the body, and replace the entity + // headers that described the original raw bytes. + const serialized: string | undefined = JSON.stringify(parsedBody); + headers.delete('content-encoding'); + headers.delete('transfer-encoding'); + if (serialized === undefined) { + headers.delete('content-length'); + } else { + body = serialized; + headers.set('content-length', String(new TextEncoder().encode(serialized).byteLength)); + } + } + } + + return new Request(url, { + method, + headers, + signal, + ...(body !== undefined && { body }) + }); +} diff --git a/packages/server/src/server/middleware/originValidation.ts b/packages/server/src/server/middleware/originValidation.ts new file mode 100644 index 0000000000..9b8b68c11e --- /dev/null +++ b/packages/server/src/server/middleware/originValidation.ts @@ -0,0 +1,98 @@ +/** + * Framework-agnostic Origin header validation helpers. + * + * Browsers attach an `Origin` header to cross-origin requests; validating it + * against an allowlist (alongside Host header validation) protects local and + * development MCP servers against DNS rebinding and cross-site request + * forgery. The framework middleware packages (`@modelcontextprotocol/express`, + * `@modelcontextprotocol/hono`, `@modelcontextprotocol/fastify`, + * `@modelcontextprotocol/node`) wrap these helpers; use them directly when + * mounting a handler bare on a fetch-native runtime. + * + * Validation is deny-on-failure: a present `Origin` value that cannot be + * parsed (including the opaque `null` origin) is rejected, never passed + * through. Requests without an `Origin` header pass — non-browser MCP clients + * do not send one. + */ + +export type OriginValidationResult = + | { ok: true; origin?: string; hostname?: string } + | { + ok: false; + errorCode: 'invalid_origin_header' | 'invalid_origin'; + message: string; + originHeader?: string; + hostname?: string; + }; + +/** + * Validate an `Origin` header against an allowlist of hostnames (port-agnostic). + * + * - A missing/empty `Origin` header passes: non-browser clients do not send one, + * and only browser-originated requests carry the header this check defends against. + * - Allowlist items are hostnames only (no scheme, no port), the same convention as + * `validateHostHeader`. For IPv6, include brackets (e.g. `[::1]`). + * - Any present value that cannot be parsed as an origin URL — including the literal + * `null` origin browsers send for opaque contexts — is rejected (deny on failure). + */ +export function validateOriginHeader(originHeader: string | null | undefined, allowedOriginHostnames: string[]): OriginValidationResult { + if (originHeader === null || originHeader === undefined || originHeader === '') { + return { ok: true }; + } + + let hostname: string; + try { + hostname = new URL(originHeader).hostname; + } catch { + return { ok: false, errorCode: 'invalid_origin_header', message: `Invalid Origin header: ${originHeader}`, originHeader }; + } + if (hostname === '') { + // Opaque origins ("null") and other non-hierarchical values parse without a + // hostname; they can never be allowlisted. + return { ok: false, errorCode: 'invalid_origin_header', message: `Invalid Origin header: ${originHeader}`, originHeader }; + } + + if (!allowedOriginHostnames.includes(hostname)) { + return { ok: false, errorCode: 'invalid_origin', message: `Invalid Origin: ${hostname}`, originHeader, hostname }; + } + + return { ok: true, origin: originHeader, hostname }; +} + +/** + * Convenience allowlist of localhost-class origin hostnames, mirroring + * `localhostAllowedHostnames`. + */ +export function localhostAllowedOrigins(): string[] { + return ['localhost', '127.0.0.1', '[::1]']; +} + +/** + * Web-standard `Request` helper for Origin validation: returns a `403` JSON-RPC + * error response when the request's `Origin` header is not allowed, and + * `undefined` when the request may proceed. + * + * ```ts + * const rejected = originValidationResponse(request, localhostAllowedOrigins()); + * if (rejected) return rejected; + * ``` + */ +export function originValidationResponse(req: Request, allowedOriginHostnames: string[]): Response | undefined { + const result = validateOriginHeader(req.headers.get('origin'), allowedOriginHostnames); + if (result.ok) return undefined; + + return Response.json( + { + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }, + { + status: 403, + headers: { 'Content-Type': 'application/json' } + } + ); +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index e783940536..fbbafb50c9 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -81,6 +81,51 @@ export type ServerOptions = ProtocolOptions & { jsonSchemaValidator?: jsonSchemaValidator; }; +/* + * Package-internal hooks for the per-request (2026-07-28) HTTP serving entry. + * + * The connection-scoped client-identity fields and the modern-only handler set are + * private to `Server`; the per-request entry in this package needs to write/install + * them on the fresh instance it gets from a consumer factory. The static initializer + * below hands these module-scoped closures privileged access; the exported wrappers + * are imported by sibling modules in this package only and are deliberately NOT + * re-exported from the package index (they are not public API). + */ +let writeClientIdentity: (server: Server, identity: PerRequestClientIdentity) => void; +let installDiscoverHandler: (server: Server, servedModernVersions: readonly string[]) => void; + +/** Connection-scoped client-identity fields backfilled per request from a validated `_meta` envelope. */ +export interface PerRequestClientIdentity { + /** The client's name/version information, when the envelope carried it. */ + clientInfo?: Implementation; + /** The client's declared capabilities, when the envelope carried them. */ + clientCapabilities?: ClientCapabilities; +} + +/** + * Package-internal: backfills the connection-scoped client-identity fields of a + * per-request server instance from the request's validated `_meta` envelope, so the + * (deprecated) {@linkcode Server.getClientCapabilities} / {@linkcode Server.getClientVersion} + * accessors keep answering on instances that never see an `initialize` handshake. + * Not public API. + */ +export function seedClientIdentityFromEnvelope(server: Server, identity: PerRequestClientIdentity): void { + writeClientIdentity(server, identity); +} + +/** + * Package-internal: installs the modern-only `server/discover` handler on an instance + * the HTTP entry has marked as serving the 2026-07-28 era, and makes sure the modern + * revisions the entry serves appear in the instance's supported-versions list (so the + * discover advertisement and version-mismatch errors name them). Idempotent. + * Hand-constructed instances are unaffected: nothing else calls this, so they keep + * answering `-32601` unless their own supported-versions list opts into a modern + * revision. Not public API. + */ +export function installModernOnlyHandlers(server: Server, servedModernVersions: readonly string[]): void { + installDiscoverHandler(server, servedModernVersions); +} + /** * An MCP server on top of a pluggable transport. * @@ -91,6 +136,26 @@ export type ServerOptions = ProtocolOptions & { export class Server extends Protocol { private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; + + static { + writeClientIdentity = (server, identity) => { + if (identity.clientCapabilities !== undefined) { + server._clientCapabilities = identity.clientCapabilities; + } + if (identity.clientInfo !== undefined) { + server._clientVersion = identity.clientInfo; + } + }; + installDiscoverHandler = (server, servedModernVersions) => { + const missing = servedModernVersions.filter(version => !server._supportedProtocolVersions.includes(version)); + if (missing.length > 0) { + // Never mutate the existing array in place: the default supported-versions + // list is a shared module constant. + server._supportedProtocolVersions = [...server._supportedProtocolVersions, ...missing]; + } + server.setRequestHandler('server/discover', () => server._ondiscover()); + }; + } private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -432,6 +497,12 @@ export class Server extends Protocol { /** * After initialization has completed, this will be populated with the client's reported capabilities. + * + * @deprecated Read client identity from the per-request handler context instead: on + * 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` carries the client's + * declared capabilities, while on 2025-era connections this accessor keeps returning the + * `initialize`-scoped value. The accessor remains functional — instances serving the + * 2026-07-28 era are backfilled per request from the validated envelope. */ getClientCapabilities(): ClientCapabilities | undefined { return this._clientCapabilities; @@ -439,6 +510,12 @@ export class Server extends Protocol { /** * After initialization has completed, this will be populated with information about the client's name and version. + * + * @deprecated Read client identity from the per-request handler context instead: on + * 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` carries the client's + * name and version, while on 2025-era connections this accessor keeps returning the + * `initialize`-scoped value. The accessor remains functional — instances serving the + * 2026-07-28 era are backfilled per request from the validated envelope. */ getClientVersion(): Implementation | undefined { return this._clientVersion; @@ -448,6 +525,12 @@ export class Server extends Protocol { * After initialization has completed, this will be populated with the protocol version negotiated * with the client (the version the server responded with during the initialize handshake), or * `undefined` before initialization. + * + * @deprecated Read the protocol revision from the per-request handler context instead: on + * 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` names the revision the + * request was sent for, while on 2025-era connections this accessor keeps returning the + * `initialize`-negotiated version. The accessor remains functional — instances serving the + * 2026-07-28 era report that revision. */ getNegotiatedProtocolVersion(): string | undefined { return this._negotiatedProtocolVersion; diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts new file mode 100644 index 0000000000..0170a12ead --- /dev/null +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -0,0 +1,849 @@ +/** + * createMcpHandler: the slot-model HTTP entry. + * + * Covers the three slot states (omitted → modern-only strict, 'stateless' → + * per-request legacy sugar, handler → bring-your-own), the handler faces, the + * per-request era write + client-identity backfill, notification routing, the + * response-mode knob, and close() teardown of the modern leg. + */ +import { Readable } from 'node:stream'; + +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import type { McpRequestContext, NodeServerResponseLike } from '../../src/server/createMcpHandler.js'; +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; + +const MODERN_REVISION = '2026-07-28'; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'entry-test-client', version: '3.2.1' }, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } } +}; + +interface JSONRPCErrorBody { + jsonrpc: string; + id: unknown; + error: { code: number; message: string; data?: Record }; +} + +function modernToolsCall(name: string, args: Record, envelope: Record = ENVELOPE): unknown { + return { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args, _meta: envelope } + }; +} + +function postRequest(body: unknown, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...headers + }, + body: typeof body === 'string' ? body : JSON.stringify(body) + }); +} + +interface TestFactoryState { + contexts: McpRequestContext[]; + products: McpServer[]; + oninitializedCalls: number; +} + +function testFactory(): { factory: (ctx: McpRequestContext) => McpServer; state: TestFactoryState } { + const state: TestFactoryState = { contexts: [], products: [], oninitializedCalls: 0 }; + const factory = (ctx: McpRequestContext): McpServer => { + state.contexts.push(ctx); + const mcpServer = new McpServer({ name: 'entry-test-server', version: '1.0.0' }); + mcpServer.server.oninitialized = () => { + state.oninitializedCalls += 1; + }; + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx2) => ({ + content: [{ type: 'text', text: ctx2.http?.authInfo?.clientId ?? 'anonymous' }] + })); + mcpServer.registerTool('progress-then-echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }, ctx2) => { + await ctx2.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'tok', progress: 1 } }); + return { content: [{ type: 'text', text }] }; + }); + mcpServer.registerTool('park', { inputSchema: z.object({}) }, async (_args, ctx2) => { + await new Promise(resolve => { + ctx2.mcpReq.signal.addEventListener('abort', () => resolve(), { once: true }); + }); + return { content: [{ type: 'text', text: 'aborted' }] }; + }); + state.products.push(mcpServer); + return mcpServer; + }; + return { factory, state }; +} + +describe('createMcpHandler — modern path', () => { + it('serves an envelope-carrying request on a fresh modern instance', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'hello' }))); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hello'); + + expect(state.contexts).toHaveLength(1); + expect(state.contexts[0]?.era).toBe('modern'); + expect(state.contexts[0]?.requestInfo).toBeInstanceOf(Request); + }); + + it('serves server/discover on the modern path with the modern supported list', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: { _meta: ENVELOPE } }) + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { supportedVersions: string[]; serverInfo: { name: string } } }; + expect(body.result.supportedVersions).toEqual([MODERN_REVISION]); + expect(body.result.serverInfo.name).toBe('entry-test-server'); + }); + + it('backfills the deprecated accessors and the negotiated revision from the validated envelope (per-request instance state)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }))); + expect(response.status).toBe(200); + + const server = state.products[0]!.server; + expect(server.getClientVersion()).toEqual({ name: 'entry-test-client', version: '3.2.1' }); + expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); + expect(server.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); + }); + + it('never fires oninitialized on the modern path and never needs setProtocolVersion on the per-request transport', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + // A 2026-classified `notifications/initialized` (modern header, no body claim) + // is acknowledged but the era registry has no such notification, so the + // legacy lifecycle callback structurally cannot fire. + const response = await handler.fetch( + postRequest( + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'notifications/initialized' } + ) + ); + expect(response.status).toBe(202); + expect(state.oninitializedCalls).toBe(0); + + // The legacy transport's setProtocolVersion side effect is moot by construction: + // the per-request transport does not implement the optional hook at all. + const transport = new PerRequestHTTPServerTransport({ classification: { era: 'modern', revision: MODERN_REVISION } }); + expect((transport as { setProtocolVersion?: unknown }).setProtocolVersion).toBeUndefined(); + }); + + it('passes caller-supplied authInfo through to handler context and never derives it from headers', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const withAuth = await handler.fetch(postRequest(modernToolsCall('whoami', {})), { + authInfo: { token: 'verified', clientId: 'client-7', scopes: [] } + }); + const withAuthBody = (await withAuth.json()) as { result: { content: Array<{ text: string }> } }; + expect(withAuthBody.result.content[0]?.text).toBe('client-7'); + + const withoutAuth = await handler.fetch(postRequest(modernToolsCall('whoami', {}), { authorization: 'Bearer raw-header-token' })); + const withoutAuthBody = (await withoutAuth.json()) as { result: { content: Array<{ text: string }> } }; + expect(withoutAuthBody.result.content[0]?.text).toBe('anonymous'); + }); + + it('answers era-removed and unknown methods with method-not-found over HTTP 404', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const eraRemoved = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 2, method: 'logging/setLevel', params: { level: 'info', _meta: ENVELOPE } }) + ); + expect(eraRemoved.status).toBe(404); + const eraRemovedBody = (await eraRemoved.json()) as JSONRPCErrorBody; + expect(eraRemovedBody.error.code).toBe(-32_601); + expect(eraRemovedBody.id).toBe(2); + + const unknown = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 3, method: 'no/such-method', params: { _meta: ENVELOPE } })); + expect(unknown.status).toBe(404); + const unknownBody = (await unknown.json()) as JSONRPCErrorBody; + expect(unknownBody.error.code).toBe(-32_601); + expect(unknownBody.id).toBe(3); + }); + + it('rejects an envelope claiming a revision the endpoint does not serve with the supported list', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest(modernToolsCall('echo', { text: 'x' }, { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2030-01-01' })) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_004); + expect(body.error.data?.['supported']).toEqual([MODERN_REVISION]); + expect(body.error.data?.['requested']).toBe('2030-01-01'); + expect(body.id).toBe(1); + expect(state.contexts).toHaveLength(0); + }); + + it('keeps the disputed header/body mismatch cells inside the candidate code set (parameterized, not pinned)', async () => { + const { factory } = testFactory(); + const onerror = vi.fn(); + const handler = createMcpHandler(factory, { onerror }); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }), { 'mcp-protocol-version': '2025-11-25' })); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect([-32_001, -32_602, -32_004]).toContain(body.error.code); + // Whatever the disputed code lands on, the rejection echoes the request id. + expect(body.id).toBe(1); + expect(onerror).toHaveBeenCalled(); + }); + + it('answers entry-internal failures with 500/-32603 and reports them through onerror', async () => { + const onerror = vi.fn(); + const handler = createMcpHandler( + () => { + throw new Error('factory exploded'); + }, + { onerror } + ); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }))); + expect(response.status).toBe(500); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_603); + expect(body.id).toBe(1); + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'factory exploded' })); + }); + + it('closes and releases the per-request instance when a modern exchange fails internally', async () => { + const { factory, state } = testFactory(); + const onerror = vi.fn(); + let closeCalls = 0; + const failingFactory = (ctx: McpRequestContext): McpServer => { + const product = factory(ctx); + vi.spyOn(product.server, 'connect').mockRejectedValue(new Error('connect exploded')); + const realClose = product.server.close.bind(product.server); + product.server.close = async () => { + closeCalls += 1; + await realClose(); + }; + return product; + }; + const handler = createMcpHandler(failingFactory, { onerror }); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }))); + expect(response.status).toBe(500); + expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_603); + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'connect exploded' })); + expect(state.contexts).toHaveLength(1); + + // The failed exchange's instance was closed and released from the + // in-flight set: the handler's own close() finds nothing to tear down. + expect(closeCalls).toBe(1); + await handler.close(); + expect(closeCalls).toBe(1); + }); + + it('rejects a malformed envelope behind a present claim with invalid params naming the offending key', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION })) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_602); + expect(JSON.stringify(body.error.data)).toContain('clientInfo'); + expect(body.id).toBe(1); + expect(state.contexts).toHaveLength(0); + }); +}); + +describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => { + it('rejects envelope-less requests with the unsupported-protocol-version error and the supported list', async () => { + const { factory, state } = testFactory(); + const onerror = vi.fn(); + const handler = createMcpHandler(factory, { onerror }); + + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'x' } } }) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_004); + expect(body.error.data?.['supported']).toEqual([MODERN_REVISION]); + expect(body.id).toBe(1); + expect(state.contexts).toHaveLength(0); + expect(onerror).toHaveBeenCalled(); + }); + + it('rejects an envelope-less initialize naming the supported and requested versions', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest({ + jsonrpc: '2.0', + id: 'init-1', + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy', version: '1.0' }, capabilities: {} } + }) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_004); + expect(body.error.data?.['supported']).toEqual([MODERN_REVISION]); + expect(body.error.data?.['requested']).toBe('2025-11-25'); + expect(body.id).toBe('init-1'); + }); + + it('answers GET and DELETE with 405 Method not allowed', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + for (const method of ['GET', 'DELETE']) { + const response = await handler.fetch(new Request('http://localhost/mcp', { method })); + expect(response.status).toBe(405); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toBe('Method not allowed.'); + // Body-less methods carry no request id to echo. + expect(body.id).toBeNull(); + } + }); + + it('rejects batch and response-body POSTs as invalid requests', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const batch = await handler.fetch(postRequest([{ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }])); + expect(batch.status).toBe(400); + const batchBody = (await batch.json()) as JSONRPCErrorBody; + expect(batchBody.error.code).toBe(-32_600); + // A whole-array rejection corresponds to no single request: id stays null. + expect(batchBody.id).toBeNull(); + + const responseBody = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 9, result: { ok: true } })); + expect(responseBody.status).toBe(400); + const responseBodyJson = (await responseBody.json()) as JSONRPCErrorBody; + expect(responseBodyJson.error.code).toBe(-32_600); + // A posted response is not a request; there is no request id to echo. + expect(responseBodyJson.id).toBeNull(); + }); + + it('answers unparseable JSON with a parse error', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch(postRequest('{not json')); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_700); + // The id could not be read from the malformed body, so it stays null. + expect(body.id).toBeNull(); + }); + + it('acknowledges and drops legacy-classified notifications (202, never dispatched)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }, { 'mcp-method': 'something/else' }) + ); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); + // Never dispatched: no instance was even constructed, and the Mcp-Method + // header is never enforced on legacy notifications. + expect(state.contexts).toHaveLength(0); + }); + + it('routes a notification POST by the modern header when the body carries no claim', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest( + { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, + { 'mcp-protocol-version': MODERN_REVISION } + ) + ); + expect(response.status).toBe(202); + expect(state.contexts).toHaveLength(1); + expect(state.contexts[0]?.era).toBe('modern'); + }); + + it('names the modern revisions in the strict rejection data so legacy clients can discover the endpoint era', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); + const body = (await response.json()) as JSONRPCErrorBody; + // The strict rejection deliberately names the modern revisions so a legacy + // client can discover what the endpoint serves from the error alone. + expect(JSON.stringify(body.error.data)).toContain(MODERN_REVISION); + }); +}); + +describe('createMcpHandler — legacy: "stateless" sugar', () => { + it('serves a 2025-era client through the frozen stateless idiom with a fresh instance per request', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const initialize = await handler.fetch( + postRequest({ + jsonrpc: '2.0', + id: 'init-1', + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy-client', version: '1.0' }, capabilities: {} } + }) + ); + expect(initialize.status).toBe(200); + expect(await initialize.text()).toContain('"protocolVersion":"2025-11-25"'); + + const toolsCall = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: { text: 'legacy hello' } } }) + ); + expect(toolsCall.status).toBe(200); + expect(await toolsCall.text()).toContain('legacy hello'); + + expect(state.contexts).toHaveLength(2); + expect(state.contexts.every(ctx => ctx.era === 'legacy')).toBe(true); + expect(state.products[0]).not.toBe(state.products[1]); + // Hand-shaped legacy serving never marks instances as modern. + expect(state.products[0]!.server.getNegotiatedProtocolVersion()).not.toBe(MODERN_REVISION); + }); + + it('answers GET and DELETE like the canonical stateless example (405, Method not allowed.)', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + for (const method of ['GET', 'DELETE']) { + const response = await handler.fetch(new Request('http://localhost/mcp', { method })); + expect(response.status).toBe(405); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toBe('Method not allowed.'); + } + }); + + it('routes legacy notification POSTs to the legacy leg (202 acknowledged by the stateless transport)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch(postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' })); + expect(response.status).toBe(202); + expect(state.contexts).toHaveLength(1); + expect(state.contexts[0]?.era).toBe('legacy'); + }); + + it('routes all-legacy batch arrays to the legacy leg unchanged', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch( + postRequest([ + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', method: 'notifications/roots/list_changed' } + ]) + ); + expect(response.status).toBe(202); + }); + + it('hands unparseable bodies to the legacy leg so the parse error stays the legacy transport answer', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch(postRequest('{not json')); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_700); + }); + + it('still serves the modern path on the same endpoint (one factory, both legs)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const modern = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'modern hello' }))); + expect(modern.status).toBe(200); + expect(await modern.text()).toContain('modern hello'); + expect(state.contexts[0]?.era).toBe('modern'); + }); + + it("reports legacy: 'stateless' leg failures through the entry's onerror instead of swallowing them", async () => { + const onerror = vi.fn(); + const handler = createMcpHandler( + ctx => { + if (ctx.era === 'legacy') { + throw new Error('legacy factory exploded'); + } + return new McpServer({ name: 'modern-only-product', version: '1.0.0' }); + }, + { legacy: 'stateless', onerror } + ); + + const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); + expect(response.status).toBe(500); + expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_603); + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'legacy factory exploded' })); + }); + + it('keeps classifier rejections authoritative on the dual arm (pins the current -32600 cells with a slot configured)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + // Parsed-but-not-JSON-RPC single object: the entry's -32600, not the + // legacy transport's -32700. + const notJsonRpc = await handler.fetch(postRequest({ hello: 'world' })); + expect(notJsonRpc.status).toBe(400); + expect(((await notJsonRpc.json()) as JSONRPCErrorBody).error.code).toBe(-32_600); + + // Empty batch: the entry's -32600/400, not the legacy leg's 202 ack. + const emptyBatch = await handler.fetch(postRequest([])); + expect(emptyBatch.status).toBe(400); + expect(((await emptyBatch.json()) as JSONRPCErrorBody).error.code).toBe(-32_600); + + // A batch containing an invalid element is rejected on both arms (element-wise classification). + const mixedBatch = await handler.fetch(postRequest([{ jsonrpc: '2.0', method: 'notifications/initialized' }, { nope: true }])); + expect(mixedBatch.status).toBe(400); + expect(((await mixedBatch.json()) as JSONRPCErrorBody).error.code).toBe(-32_600); + + // The legacy leg is never consulted for these cells. + expect(state.contexts).toHaveLength(0); + }); + + it('answers a legacy-direction server/discover with a plain method-not-found and zero 2026 vocabulary', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} })); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain('-32601'); + expect(text).toContain('Method not found'); + expect(text).not.toContain('2026'); + }); +}); + +describe('createMcpHandler — legacy: bring-your-own handler', () => { + it('hands legacy-classified requests to the handler with the original bytes untouched', async () => { + const { factory, state } = testFactory(); + const original = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }; + let receivedBody: string | undefined; + let receivedParsedBody: unknown; + const byo = vi.fn(async (request: Request, options?: { parsedBody?: unknown }) => { + receivedBody = await request.text(); + receivedParsedBody = options?.parsedBody; + return new Response('byo-served', { status: 299 }); + }); + const handler = createMcpHandler(factory, { legacy: byo }); + + const response = await handler.fetch(postRequest(original)); + expect(response.status).toBe(299); + expect(await response.text()).toBe('byo-served'); + expect(receivedBody).toBe(JSON.stringify(original)); + expect(receivedParsedBody).toEqual(original); + + // GET/DELETE are method-routed to the handler too (sessionful BYO wirings own them). + const get = await handler.fetch(new Request('http://localhost/mcp', { method: 'GET' })); + expect(get.status).toBe(299); + + // Modern envelope traffic never reaches the legacy slot. + const modern = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'hi' }))); + expect(modern.status).toBe(200); + expect(byo).toHaveBeenCalledTimes(2); + expect(state.contexts.filter(ctx => ctx.era === 'modern')).toHaveLength(1); + }); +}); + +describe('createMcpHandler — responseMode', () => { + it('defaults to the lazy upgrade: a handler emitting a related notification streams the exchange over SSE', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch(postRequest(modernToolsCall('progress-then-echo', { text: 'streamed' }))); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + const text = await response.text(); + expect(text).toContain('notifications/progress'); + expect(text).toContain('streamed'); + }); + + it("responseMode: 'json' never streams and drops mid-call notifications", async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { responseMode: 'json' }); + + const response = await handler.fetch(postRequest(modernToolsCall('progress-then-echo', { text: 'json only' }))); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const text = await response.text(); + expect(text).not.toContain('notifications/progress'); + expect(text).toContain('json only'); + }); + + it("responseMode: 'sse' streams even when the handler emits nothing before its result", async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { responseMode: 'sse' }); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'eager stream' }))); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + expect(await response.text()).toContain('eager stream'); + }); +}); + +describe('createMcpHandler — handler faces', () => { + it('exposes a detach-safe fetch face', async () => { + const { factory } = testFactory(); + const { fetch: detachedFetch } = createMcpHandler(factory); + const response = await detachedFetch(postRequest(modernToolsCall('echo', { text: 'detached' }))); + expect(response.status).toBe(200); + expect(await response.text()).toContain('detached'); + }); + + it('serves through the duck-typed node face, reading the request stream when no parsed body is given', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const { req, res, body } = nodeRequestResponse(modernToolsCall('echo', { text: 'node face' })); + // Express mounts pass `next` as the third argument; a function is never a parsed body. + await handler.node(req, res, () => {}); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('node face'); + }); + + it('prefers a pre-parsed body over the request stream on the node face', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const parsed = modernToolsCall('echo', { text: 'pre-parsed' }); + const { req, res, body } = nodeRequestResponse(undefined); + await handler.node(req, res, parsed); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('pre-parsed'); + }); + + it('synthesizes the forwarded body from a pre-parsed body so node-face BYO legacy handlers can read it', async () => { + const { factory } = testFactory(); + const legacyMessage = { jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }; + let receivedText: string | undefined; + let receivedContentLength: string | null = null; + let receivedTransferEncoding: string | null = null; + const byo = async (request: Request) => { + receivedText = await request.text(); + receivedContentLength = request.headers.get('content-length'); + receivedTransferEncoding = request.headers.get('transfer-encoding'); + return new Response('byo-node-served', { status: 200 }); + }; + const handler = createMcpHandler(factory, { legacy: byo }); + + // The documented Express mounting: express.json() consumed the stream + // and hands the parsed object as the third argument; the raw headers + // still describe the original (already-consumed) bytes. + const { req, res, body } = nodeRequestResponse(undefined); + req.headers['content-length'] = '999'; + req.headers['transfer-encoding'] = 'chunked'; + await handler.node(req, res, legacyMessage); + + expect(res.statusCode).toBe(200); + expect(await body()).toBe('byo-node-served'); + expect(receivedText).toBe(JSON.stringify(legacyMessage)); + expect(receivedContentLength).toBe(String(JSON.stringify(legacyMessage).length)); + expect(receivedTransferEncoding).toBeNull(); + }); + + it('forwards req.auth from upstream middleware as pass-through authInfo on the node face', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const { req, res, body } = nodeRequestResponse(modernToolsCall('whoami', {})); + req.auth = { token: 'verified', clientId: 'node-client', scopes: [] }; + await handler.node(req, res); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('node-client'); + }); + + it('skips HTTP/2 pseudo-headers when copying node request headers', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const { req, res, body } = nodeRequestResponse(modernToolsCall('echo', { text: 'http2 served' })); + Object.assign(req.headers, { + ':method': 'POST', + ':path': '/mcp', + ':scheme': 'http', + ':authority': 'localhost:3000' + }); + await handler.node(req, res); + + expect(res.statusCode).toBe(200); + expect(await body()).toContain('http2 served'); + }); + + it('waits for drain before writing the next chunk when res.write reports backpressure', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const writes: string[] = []; + const listeners = new Map void>>(); + const res: NodeServerResponseLike & { statusCode: number } = { + statusCode: 0, + writeHead(statusCode: number) { + this.statusCode = statusCode; + return this; + }, + write(chunk: string | Uint8Array) { + writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); + // Always report a full buffer. + return false; + }, + end() { + return this; + }, + on(event: string, listener: (...args: unknown[]) => void) { + const existing = listeners.get(event) ?? []; + existing.push(listener); + listeners.set(event, existing); + return this; + } + }; + const emitDrain = () => { + for (const listener of listeners.get('drain') ?? []) { + listener(); + } + }; + + // The default (auto) response mode streams this exchange over SSE, so + // the loop sees at least two chunks (the progress frame and the result). + const { req } = nodeRequestResponse(modernToolsCall('progress-then-echo', { text: 'paced' })); + const served = handler.node(req, res); + + await vi.waitFor(() => expect(writes.length).toBe(1)); + // With the buffer reported full and no drain yet, no further chunk is written. + await new Promise(resolve => setTimeout(resolve, 25)); + expect(writes).toHaveLength(1); + + // Draining releases the loop chunk by chunk until the stream completes. + const pump = setInterval(emitDrain, 5); + await served; + clearInterval(pump); + + const streamed = writes.join(''); + expect(writes.length).toBeGreaterThan(1); + expect(streamed).toContain('notifications/progress'); + expect(streamed).toContain('paced'); + }); +}); + +describe('createMcpHandler — close()', () => { + it('aborts in-flight modern exchanges and refuses further requests', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const pending = handler.fetch(postRequest(modernToolsCall('park', {}))); + // Give the exchange time to reach the parked handler before tearing down. + await new Promise(resolve => setTimeout(resolve, 50)); + await handler.close(); + + const response = await pending; + expect(response.status).toBe(499); + + await expect(handler.fetch(postRequest(modernToolsCall('echo', { text: 'late' })))).rejects.toThrow(/closed/); + }); + + it('leaves the legacy slot untouched by close() until the handler itself refuses requests', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + await handler.close(); + await expect(handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }))).rejects.toThrow(/closed/); + }); +}); + +/* ------------------------------------------------------------------------ * + * Node face fixtures (duck-typed, no real sockets) + * ------------------------------------------------------------------------ */ + +interface FakeNodeResponse extends NodeServerResponseLike { + statusCode: number; + headers: Record | undefined; +} + +function nodeRequestResponse(body: unknown): { + req: Readable & { + method: string; + url: string; + headers: Record; + auth?: { token: string; clientId: string; scopes: string[] }; + }; + res: FakeNodeResponse; + body: () => Promise; +} { + const payload = body === undefined ? [] : [JSON.stringify(body)]; + const req = Object.assign(Readable.from(payload), { + method: 'POST', + url: '/mcp', + headers: { + host: 'localhost:3000', + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + } as Record + }); + + const chunks: string[] = []; + let resolveFinished: () => void; + const finished = new Promise(resolve => { + resolveFinished = resolve; + }); + const res: FakeNodeResponse = { + statusCode: 0, + headers: undefined, + writeHead(statusCode: number, headers?: Record) { + this.statusCode = statusCode; + this.headers = headers; + return this; + }, + write(chunk: string | Uint8Array) { + chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); + return true; + }, + end(chunk?: string | Uint8Array) { + if (chunk !== undefined) { + this.write(chunk); + } + resolveFinished(); + return this; + }, + on() { + return this; + } + }; + + return { + req, + res, + body: async () => { + await finished; + return chunks.join(''); + } + }; +} + +// Type-level pin: a zero-argument factory stays assignable to McpServerFactory unchanged. +const zeroArgFactory = () => new McpServer({ name: 'zero-arg', version: '1.0.0' }); +void createMcpHandler(zeroArgFactory); diff --git a/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts b/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts new file mode 100644 index 0000000000..fff1734cb2 --- /dev/null +++ b/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts @@ -0,0 +1,83 @@ +/** + * Wire-level continuity twin for the "Unsupported protocol version" rejection, + * exercised through `createMcpHandler(factory, { legacy: 'stateless' })`. + * + * The legacy slot routes 2025-era traffic through the untouched streamable HTTP + * transport, so the rejection site (and therefore the wire bytes deployed + * clients sniff — see streamableHttpUnsupportedVersionLiteral.test.ts for the + * go-sdk substring dependency) is the same one the standalone transport test + * pins. This twin asserts the bytes hold on the sugar path itself: HTTP 400, + * code -32000, and the literal substring `Unsupported protocol version`, with + * the supported-versions suffix derived from `SUPPORTED_PROTOCOL_VERSIONS`. + */ +import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +interface JSONRPCErrorBody { + jsonrpc: string; + id: unknown; + error: { code: number; message: string }; +} + +function factory(): McpServer { + const mcpServer = new McpServer({ name: 'literal-twin', version: '1.0.0' }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return mcpServer; +} + +function postRequest(body: unknown, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...headers + }, + body: JSON.stringify(body) + }); +} + +describe('createMcpHandler legacy:"stateless" — unsupported protocol version wire literal continuity', () => { + it('rejects an unsupported MCP-Protocol-Version header with HTTP 400, code -32000, and the sniffed literal substring', async () => { + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + // The probe header is an unsupported 2025-era version string: that is what a + // deployed 2025 client can actually send. (A 2026-or-later header on a body + // without an envelope claim is a header/body cross-check disagreement and is + // answered by the classifier before legacy serving is reached.) + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 'tools-1', method: 'tools/list', params: {} }, { 'mcp-protocol-version': '2024-01-01' }) + ); + + expect(response.status).toBe(400); + expect(response.headers.get('content-type')).toContain('application/json'); + + const rawBody = await response.text(); + // The substring deployed clients (go-sdk) sniff must appear verbatim in the wire bytes. + expect(rawBody).toContain('Unsupported protocol version'); + + const body = JSON.parse(rawBody) as JSONRPCErrorBody; + expect(body.jsonrpc).toBe('2.0'); + expect(body.id).toBeNull(); + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toBe( + `Bad Request: Unsupported protocol version: 2024-01-01 (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})` + ); + }); + + it('keeps serving supported 2025-era traffic on the same path (the rejection is header-keyed, not blanket)', async () => { + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 'tools-1', method: 'tools/list', params: {} }, { 'mcp-protocol-version': '2025-11-25' }) + ); + expect(response.status).toBe(200); + expect(await response.text()).toContain('"tools"'); + }); +}); diff --git a/packages/server/test/server/legacyStatelessFallback.test.ts b/packages/server/test/server/legacyStatelessFallback.test.ts new file mode 100644 index 0000000000..8958a3516b --- /dev/null +++ b/packages/server/test/server/legacyStatelessFallback.test.ts @@ -0,0 +1,184 @@ +/** + * legacyStatelessFallback — the canonical `legacy` slot value, tested + * independently of createMcpHandler: per-request stateless serving via the + * frozen idiom (fresh instance + sessionIdGenerator: undefined + handleRequest). + */ +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import type { McpRequestContext } from '../../src/server/createMcpHandler.js'; +import { legacyStatelessFallback } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +interface JSONRPCErrorBody { + jsonrpc: string; + id: unknown; + error: { code: number; message: string }; +} + +function postRequest(body: unknown): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(body) + }); +} + +describe('legacyStatelessFallback', () => { + it('serves each POST on a fresh instance from the factory (stateless idiom)', async () => { + const contexts: McpRequestContext[] = []; + const products: McpServer[] = []; + const handler = legacyStatelessFallback(ctx => { + contexts.push(ctx); + const mcpServer = new McpServer({ name: 'fallback-test', version: '1.0.0' }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + products.push(mcpServer); + return mcpServer; + }); + + const first = await handler( + postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'one' } } }) + ); + expect(first.status).toBe(200); + expect(await first.text()).toContain('one'); + + const second = await handler( + postRequest({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: { text: 'two' } } }) + ); + expect(second.status).toBe(200); + expect(await second.text()).toContain('two'); + + expect(products).toHaveLength(2); + expect(products[0]).not.toBe(products[1]); + expect(contexts.every(ctx => ctx.era === 'legacy')).toBe(true); + }); + + it('passes caller-provided authInfo and parsedBody through to the legacy transport', async () => { + let seenClientId: string | undefined; + const handler = legacyStatelessFallback(() => { + const mcpServer = new McpServer({ name: 'fallback-auth', version: '1.0.0' }); + mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx) => { + seenClientId = ctx.http?.authInfo?.clientId; + return { content: [{ type: 'text', text: 'ok' }] }; + }); + return mcpServer; + }); + + const body = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'whoami', arguments: {} } }; + const response = await handler(postRequest(body), { + authInfo: { token: 'verified', clientId: 'fallback-client', scopes: [] }, + parsedBody: body + }); + expect(response.status).toBe(200); + // Drain the exchange before asserting: the tool handler runs while the + // per-request stream is open. + expect(await response.text()).toContain('ok'); + expect(seenClientId).toBe('fallback-client'); + }); + + it('answers GET and DELETE with 405 / Method not allowed. like the canonical stateless example', async () => { + const handler = legacyStatelessFallback(() => new McpServer({ name: 'fallback-405', version: '1.0.0' })); + + for (const method of ['GET', 'DELETE']) { + const response = await handler(new Request('http://localhost/mcp', { method })); + expect(response.status).toBe(405); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toBe('Method not allowed.'); + expect(body.id).toBeNull(); + } + }); + + it('tears the per-request pair down after a normally-completed SSE exchange (factory product close hooks fire)', async () => { + let productClosed = false; + const handler = legacyStatelessFallback(() => { + const mcpServer = new McpServer({ name: 'fallback-teardown', version: '1.0.0' }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + mcpServer.server.onclose = () => { + productClosed = true; + }; + return mcpServer; + }); + + const response = await handler( + postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'all done' } } }) + ); + expect(response.status).toBe(200); + // Request-bearing POSTs are answered over SSE by the stateless idiom's + // default transport options — the dominant legacy exchange shape. + expect(response.headers.get('content-type')).toContain('text/event-stream'); + expect(productClosed).toBe(false); + + // Drain the stream to completion: only then is the exchange over. + expect(await response.text()).toContain('all done'); + await vi.waitFor(() => { + expect(productClosed).toBe(true); + }); + }); + + it('still tears the per-request pair down when the client aborts a streaming exchange', async () => { + let productClosed = false; + const handler = legacyStatelessFallback(ctx => { + const mcpServer = new McpServer({ name: 'fallback-abort', version: '1.0.0' }); + mcpServer.registerTool('park', { inputSchema: z.object({}) }, async (_args, toolCtx) => { + await new Promise(resolve => { + toolCtx.mcpReq.signal.addEventListener('abort', () => resolve(), { once: true }); + }); + return { content: [{ type: 'text', text: `parked on ${ctx.era}` }] }; + }); + mcpServer.server.onclose = () => { + productClosed = true; + }; + return mcpServer; + }); + + const controller = new AbortController(); + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'park', arguments: {} } }), + signal: controller.signal + }); + + const response = await handler(request); + expect(response.status).toBe(200); + expect(productClosed).toBe(false); + + controller.abort(); + await vi.waitFor(() => { + expect(productClosed).toBe(true); + }); + }); + + it('answers factory failures with a 500 internal error body', async () => { + const handler = legacyStatelessFallback(() => { + throw new Error('factory exploded'); + }); + const response = await handler(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); + expect(response.status).toBe(500); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_603); + }); + + it('reports failures through the optional onerror callback while keeping the 500 response', async () => { + const onerror = vi.fn(); + const handler = legacyStatelessFallback(() => { + throw new Error('factory exploded'); + }, onerror); + + const response = await handler(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); + expect(response.status).toBe(500); + expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_603); + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'factory exploded' })); + }); +}); diff --git a/packages/server/test/server/originValidation.test.ts b/packages/server/test/server/originValidation.test.ts new file mode 100644 index 0000000000..e0a3d4ab43 --- /dev/null +++ b/packages/server/test/server/originValidation.test.ts @@ -0,0 +1,67 @@ +/** + * Framework-agnostic Origin validation helpers: allowlist matching, the + * absent-header pass, and the deny-on-failure behavior for malformed values. + */ +import { describe, expect, it } from 'vitest'; + +import { localhostAllowedOrigins, originValidationResponse, validateOriginHeader } from '../../src/server/middleware/originValidation.js'; + +describe('validateOriginHeader', () => { + it('passes when no Origin header is present (non-browser clients)', () => { + expect(validateOriginHeader(undefined, ['localhost']).ok).toBe(true); + expect(validateOriginHeader(null, ['localhost']).ok).toBe(true); + expect(validateOriginHeader('', ['localhost']).ok).toBe(true); + }); + + it('allows origins whose hostname is on the allowlist, port- and scheme-agnostic', () => { + expect(validateOriginHeader('http://localhost:3000', ['localhost']).ok).toBe(true); + expect(validateOriginHeader('https://localhost', ['localhost']).ok).toBe(true); + expect(validateOriginHeader('http://127.0.0.1:8080', localhostAllowedOrigins()).ok).toBe(true); + expect(validateOriginHeader('http://[::1]:8080', localhostAllowedOrigins()).ok).toBe(true); + }); + + it('rejects origins whose hostname is not on the allowlist', () => { + const result = validateOriginHeader('http://evil.example.com', localhostAllowedOrigins()); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe('invalid_origin'); + expect(result.message).toContain('evil.example.com'); + } + }); + + it('rejects lookalike subdomains of allowed hostnames', () => { + expect(validateOriginHeader('http://localhost.evil.example.com', localhostAllowedOrigins()).ok).toBe(false); + }); + + it('denies on failure: unparseable Origin values and the opaque null origin are rejected, never passed through', () => { + for (const malformed of ['null', 'not a url', 'evil.example.com', 'about:blank']) { + const result = validateOriginHeader(malformed, localhostAllowedOrigins()); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe('invalid_origin_header'); + } + } + }); +}); + +describe('originValidationResponse', () => { + it('returns undefined for allowed and absent origins', () => { + const allowed = new Request('http://localhost/mcp', { headers: { origin: 'http://localhost:3000' } }); + expect(originValidationResponse(allowed, localhostAllowedOrigins())).toBeUndefined(); + + const absent = new Request('http://localhost/mcp'); + expect(originValidationResponse(absent, localhostAllowedOrigins())).toBeUndefined(); + }); + + it('returns a 403 JSON-RPC error response for disallowed origins', async () => { + const request = new Request('http://localhost/mcp', { headers: { origin: 'http://evil.example.com' } }); + const response = originValidationResponse(request, localhostAllowedOrigins()); + expect(response).toBeDefined(); + expect(response!.status).toBe(403); + const body = (await response!.json()) as { jsonrpc: string; error: { code: number; message: string }; id: unknown }; + expect(body.jsonrpc).toBe('2.0'); + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toContain('Invalid Origin'); + expect(body.id).toBeNull(); + }); +}); diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts new file mode 100644 index 0000000000..61e78d13d6 --- /dev/null +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -0,0 +1,165 @@ +/** + * createMcpHandler served over real HTTP, driven by real clients: the + * 2026-capable negotiation client for the modern path and a plain 2025 client + * for the legacy slot — the three slot states on one endpoint, all backed by + * one factory. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { CallToolResult, CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, legacyStatelessFallback, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +const MODERN = '2026-07-28'; + +describe('createMcpHandler over HTTP (slot states end to end)', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + // One factory for both legs: the era only shows up in the tool output so the + // tests can see which leg served the call. + const factory = (ctx: McpRequestContext) => { + const mcpServer = new McpServer( + { name: 'dual-era-endpoint', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'dual era endpoint' } + ); + mcpServer.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (${ctx.era})` }] + })); + return mcpServer; + }; + + async function startEndpoint(options?: CreateMcpHandlerOptions): Promise<{ baseUrl: URL; handler: McpHttpHandler }> { + const handler = createMcpHandler(factory, options); + const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await handler.close(); + httpServer.close(); + }); + return { baseUrl, handler }; + } + + function modernEnvelope() { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'integration-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + } + + it('serves the modern era to an auto-negotiating client (strict endpoint, no legacy slot)', async () => { + const { baseUrl } = await startEndpoint(); + + const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(client.getServerVersion()).toEqual({ name: 'dual-era-endpoint', version: '1.0.0' }); + expect(client.getInstructions()).toBe('dual era endpoint'); + + // A typed tools/call round trip; the per-request envelope is attached + // explicitly here (automatic envelope emission for every modern request + // is a client-side follow-up). + const result = (await client.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'modern' }, _meta: modernEnvelope() } + })) as CallToolResult; + expect(result.content).toEqual([{ type: 'text', text: 'hello modern (modern)' }]); + }); + + it('rejects a plain 2025 client on the strict endpoint with the unsupported-protocol-version error', async () => { + const { baseUrl } = await startEndpoint(); + + const client = new Client({ name: 'legacy-client', version: '1.0.0' }); + await expect(client.connect(new StreamableHTTPClientTransport(baseUrl))).rejects.toThrow(/Unsupported protocol version|400/); + cleanups.push(() => client.close().catch(() => {})); + }); + + it("serves a plain 2025 client through the 'stateless' legacy slot while the modern path keeps working", async () => { + const { baseUrl } = await startEndpoint({ legacy: 'stateless' }); + + const legacyClient = new Client({ name: 'legacy-client', version: '1.0.0' }); + await legacyClient.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => legacyClient.close()); + + expect(legacyClient.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + const legacyResult = await legacyClient.callTool({ name: 'greet', arguments: { name: 'old friend' } }); + expect(legacyResult.content).toEqual([{ type: 'text', text: 'hello old friend (legacy)' }]); + + const modernClient = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await modernClient.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => modernClient.close()); + + expect(modernClient.getNegotiatedProtocolVersion()).toBe(MODERN); + const modernResult = (await modernClient.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'new friend' }, _meta: modernEnvelope() } + })) as CallToolResult; + expect(modernResult.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); + }); + + it('serves a plain 2025 client through a bring-your-own legacy handler', async () => { + const { baseUrl } = await startEndpoint({ legacy: legacyStatelessFallback(factory) }); + + const client = new Client({ name: 'legacy-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + const result = await client.callTool({ name: 'greet', arguments: { name: 'byo' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello byo (legacy)' }]); + }); + + it('pinning the modern revision works against the entry and never sends initialize', async () => { + const { baseUrl } = await startEndpoint({ legacy: 'stateless' }); + + const bodies: string[] = []; + const recordingFetch: typeof fetch = async (input, init) => { + if (typeof init?.body === 'string') bodies.push(init.body); + return fetch(input, init); + }; + + const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: recordingFetch })); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(bodies.some(body => body.includes('"initialize"'))).toBe(false); + expect(bodies[0]).toContain('server/discover'); + }); + + it('answers an envelope claiming an unsupported revision with the supported list over plain HTTP', async () => { + const { baseUrl } = await startEndpoint(); + + // A request whose envelope claims an unsupported revision is answered with + // the unsupported-protocol-version error over plain HTTP 400. + const response = await fetch(new URL('/mcp', baseUrl), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'x' }, + _meta: { ...modernEnvelope(), [PROTOCOL_VERSION_META_KEY]: '2030-01-01' } + } + }) + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { id: unknown; error: { code: number; data: { supported: string[] } } }; + expect(body.error.code).toBe(-32_004); + expect(body.error.data.supported).toEqual([MODERN]); + // The rejection echoes the request id it answers (it could be read from the body). + expect(body.id).toBe(1); + }); +}); From ea53b041ebe09b954540d59aa5800ba16d696ba4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:14:33 +0100 Subject: [PATCH 15/37] feat(server): opt-in stdio dual-era serving via ServerOptions.eraSupport; dual-era examples (#2305) --- .changeset/client-modern-era-inbound-drop.md | 6 + .changeset/server-era-support.md | 9 + docs/migration-SKILL.md | 9 + docs/migration.md | 40 +- docs/server.md | 14 + examples/client/src/dualEraStdioClient.ts | 68 +++ examples/server/src/dualEraStdio.ts | 68 +++ packages/client/src/client/client.ts | 24 ++ .../test/client/modernEraInboundDrop.test.ts | 109 +++++ .../test/client/probeFixtureCorpus.test.ts | 231 +++++++++++ .../core/src/shared/inboundClassification.ts | 44 ++ packages/core/src/shared/protocol.ts | 76 +++- packages/core/src/types/types.ts | 9 +- packages/core/src/wire/codec.ts | 14 +- .../shared/classifyInboundMessage.test.ts | 107 +++++ .../protocolClassifyInboundHook.test.ts | 255 ++++++++++++ packages/server/src/server/server.ts | 252 +++++++++++- packages/server/test/server/discover.test.ts | 43 +- .../server/test/server/dualEraServing.test.ts | 384 +++++++++++++++++ .../server/test/server/eraSupport.test.ts | 389 ++++++++++++++++++ .../test/__fixtures__/dualEraStdioServer.ts | 29 ++ test/integration/test/client/client.test.ts | 12 +- .../test/client/discoverRoundtrip.test.ts | 42 +- .../test/server/dualEraStdio.test.ts | 194 +++++++++ 24 files changed, 2356 insertions(+), 72 deletions(-) create mode 100644 .changeset/client-modern-era-inbound-drop.md create mode 100644 .changeset/server-era-support.md create mode 100644 examples/client/src/dualEraStdioClient.ts create mode 100644 examples/server/src/dualEraStdio.ts create mode 100644 packages/client/test/client/modernEraInboundDrop.test.ts create mode 100644 packages/client/test/client/probeFixtureCorpus.test.ts create mode 100644 packages/core/test/shared/classifyInboundMessage.test.ts create mode 100644 packages/core/test/shared/protocolClassifyInboundHook.test.ts create mode 100644 packages/server/test/server/dualEraServing.test.ts create mode 100644 packages/server/test/server/eraSupport.test.ts create mode 100644 test/integration/test/__fixtures__/dualEraStdioServer.ts create mode 100644 test/integration/test/server/dualEraStdio.test.ts diff --git a/.changeset/client-modern-era-inbound-drop.md b/.changeset/client-modern-era-inbound-drop.md new file mode 100644 index 0000000000..846bcd0581 --- /dev/null +++ b/.changeset/client-modern-era-inbound-drop.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Drop inbound JSON-RPC requests on connections that negotiated the 2026-07-28 draft revision instead of answering them: the modern era has no server→client request channel (server-initiated interactions are carried in `input_required` results), and the stdio transport forbids the +client from writing JSON-RPC responses. Dropped requests are surfaced via `onerror`. Legacy-era connections, responses, and notifications are unchanged. diff --git a/.changeset/server-era-support.md b/.changeset/server-era-support.md new file mode 100644 index 0000000000..c805112f56 --- /dev/null +++ b/.changeset/server-era-support.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add `ServerOptions.eraSupport: 'legacy' | 'dual-era' | 'modern'`, the opt-in for serving the 2026-07-28 draft revision on long-lived connections such as stdio. The default is `'legacy'` and preserves today's behavior exactly: nothing 2026-era is registered or advertised, and 2025 +wire behavior is unchanged by the upgrade. `'dual-era'` serves both protocol eras on the same connection, selecting the era per message (`initialize`-negotiated 2025 traffic as before, per-request `_meta` envelope traffic — including `server/discover` — on the modern era), while +methods that exist in only one era stay invisible to the other. `'modern'` is strict 2026-only: requests without the envelope (including `initialize`) are answered with the unsupported-protocol-version error naming the supported revisions. A 2026-era revision in +`supportedProtocolVersions` now requires declaring `eraSupport` (`'dual-era'` or `'modern'`); on a default `'legacy'` instance it throws a `TypeError` at construction instead of silently installing the `server/discover` handler. On dual-era instances the deprecated +client-identity accessors keep their `initialize`-scoped semantics and are never backfilled from 2026-era requests; handlers read per-request identity from `ctx.mcpReq.envelope`. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index c708618ef0..311ae8d956 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -550,6 +550,15 @@ These can require code changes: - `Server.getClientCapabilities()`, `getClientVersion()` and `getNegotiatedProtocolVersion()` are deprecated but functional: prefer the per-request context (`ctx.mcpReq.envelope`) on 2026-07-28 requests. No mechanical change required yet; plan the move before the deprecations are removed. - `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default (requests without an `Origin` header are unaffected). Browser-served clients on a non-localhost origin need `allowedOrigins: [...]`, which replaces the default localhost allowlist — Origin validation cannot be disabled for localhost-class binds. +### Server (stdio / long-lived connections) + +- `ServerOptions.eraSupport?: 'legacy' | 'dual-era' | 'modern'` declares which protocol eras a hand-constructed `Server`/`McpServer` serves on its long-lived connection. Default `'legacy'` = today's behavior, byte-identical: do not add the option during a mechanical migration. +- Serving the 2026-07-28 draft revision on stdio is the explicit opt-in `new McpServer(info, { eraSupport: 'dual-era' })` with an unchanged `connect(new StdioServerTransport())`. `'modern'` is strict 2026-only (envelope-less requests, including `initialize`, get the + unsupported-protocol-version error). +- A 2026-era revision in `supportedProtocolVersions` now requires `eraSupport: 'dual-era' | 'modern'`; on a default (`'legacy'`) instance it throws a `TypeError` at construction (previously it silently installed the `server/discover` handler). +- On dual-era instances `getClientCapabilities()` / `getClientVersion()` / `getNegotiatedProtocolVersion()` keep `initialize`-scoped semantics and are never backfilled from 2026-era requests; handlers read per-request identity from `ctx.mcpReq.envelope`. +- A client whose connection negotiated a modern era drops inbound server→client JSON-RPC requests (the 2026 era has no such channel) instead of answering them; legacy-era connections are unchanged. + ## 14. Runtime-Specific JSON Schema Validators (Enhancement) The SDK now auto-selects the appropriate JSON Schema validator based on runtime: diff --git a/docs/migration.md b/docs/migration.md index 6705b0b0a9..ce0602cf71 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1025,9 +1025,9 @@ versionNegotiation: { } ``` -On the server side, a `Server`/`McpServer` whose `supportedProtocolVersions` list includes a 2026-era revision installs a `server/discover` handler, advertising only its modern revisions; servers with the default version list are byte-identical to before (they keep +On the server side, a `Server`/`McpServer` serves `server/discover` (advertising only its modern revisions) when it declares modern-era support via the `eraSupport` option (see the stdio section below); servers constructed without it are byte-identical to before (they keep answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the -next section; serving it over stdio arrives with a later release. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation probe +next section; serving it on stdio (and other long-lived connections) is the `eraSupport` server option described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation probe already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` @@ -1068,12 +1068,48 @@ The entry performs no Origin/Host validation (see the origin-validation middlewa request headers. Power users who want to compose routing themselves can use the exported `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around (`const { fetch } = handler`). +### Serving the 2026-07-28 draft revision on stdio: `eraSupport` + +A hand-constructed `Server`/`McpServer` — the shape every stdio server has — now takes an `eraSupport` option declaring which protocol eras it serves on its long-lived connection. **The default is `'legacy'`: if you do nothing, your server keeps speaking exactly the +2025-era protocol it was written for** — the `initialize` handshake, the same wire bytes, no `server/discover`, nothing new advertised — and upgrading the SDK changes nothing about what it puts on the wire. + +Serving the 2026-07-28 draft revision is one explicit option; the transport stays unchanged: + +```typescript +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; + +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); +await server.connect(new StdioServerTransport()); +``` + +What the values mean: + +- **`'legacy'` (default)** — today's behavior, unchanged: 2025-era serving negotiated via `initialize`. `server/discover` is not registered or advertised. Declaring a 2026-era revision in `supportedProtocolVersions` without changing `eraSupport` is now a construction-time + `TypeError` (previously it silently installed the discover handler) — serving the new revision is always an explicit declaration, never a side effect of a version list. +- **`'dual-era'`** — both eras on the same connection, selected per message: plain 2025 clients keep using `initialize` and are served exactly as before, while 2026-capable clients negotiate via `server/discover` on the same pipe and every request carrying the per-request + `_meta` envelope is served on the modern era. Methods that exist in only one era stay invisible to the other: a 2025-era client asking for a 2026-only method (such as `server/discover` without an envelope) gets the same plain `-32601` a 2025 server would send, and a + 2026-era request for a removed method (such as `logging/setLevel`) gets `-32601` too. +- **`'modern'`** — strict 2026-only: requests without the per-request envelope (including `initialize`) are answered with the unsupported-protocol-version error naming the supported revisions; legacy-era notifications are dropped. + +Declaring `'dual-era'` or `'modern'` automatically adds the SDK's supported modern revisions to `supportedProtocolVersions`, and `'modern'` serves only those: a strict instance's supported list (what `server/discover` advertises and version-mismatch errors name) is modern-only. + +Directionality follows the era of the traffic: the 2026-07-28 revision has no server→client JSON-RPC request channel, so a `'modern'` instance cannot emit `sampling`/`elicitation`/`roots` wire requests (they fail locally with a typed error), while a `'dual-era'` instance +can still send them to the 2025-era clients it serves via `initialize`. On a `'dual-era'` instance the same local typed error applies per request: a handler that is serving a 2026-era request cannot send server→client requests through its request context +(`ctx.mcpReq.send`, `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`) — only handlers serving 2025-era requests can. Symmetrically, a client whose connection negotiated a modern era drops inbound JSON-RPC requests instead of answering them. + +Declaring `eraSupport: 'dual-era'` is also an assertion that your handlers are ready to serve modern-era requests (for example, that they read per-request client identity from `ctx.mcpReq.envelope` rather than the connection-scoped accessors — see the next section). A +future release may add per-handler era declarations as the basis for a safe automatic default; for now the connection-level `eraSupport` option is the whole opt-in surface. + ### Client identity accessors deprecated in favor of per-request context `Server.getClientCapabilities()`, `Server.getClientVersion()` and `Server.getNegotiatedProtocolVersion()` are deprecated (they remain functional). On 2026-07-28 requests the client's identity travels with each request in the validated `_meta` envelope and is available to handlers as `ctx.mcpReq.envelope`; instances serving that revision through `createMcpHandler` are backfilled per request, so existing code that calls the accessors keeps working on both eras. On 2025-era connections the accessors keep returning the `initialize`-scoped values, as before. +On a long-lived dual-era instance (`eraSupport: 'dual-era'`, e.g. a stdio server) the accessors are **not** backfilled from modern requests: 2025-era and 2026-era messages interleave on one connection, so instance-level backfill would race. There the accessors keep their +`initialize`-scoped semantics — they reflect what the legacy handshake negotiated (or `undefined` when none ran) — and handlers serving 2026-era requests read the per-request identity from `ctx.mcpReq.envelope`. + ### Origin validation middleware and default arming The middleware packages now ship Origin header validation alongside the existing Host header validation, and the app factories arm it by default for localhost-class binds: diff --git a/docs/server.md b/docs/server.md index 9110d87379..1b38ac5c83 100644 --- a/docs/server.md +++ b/docs/server.md @@ -62,6 +62,20 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` +#### Serving the 2026-07-28 draft revision on stdio + +By default a stdio server speaks the 2025-era protocol it was written for (`eraSupport: 'legacy'`): nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision is one explicit option — the transport stays unchanged: + +```typescript +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); +await server.connect(new StdioServerTransport()); +``` + +With `eraSupport: 'dual-era'` the same long-lived connection serves both eras, selected per message: plain 2025 clients keep using `initialize` and are served exactly as before, while 2026-capable clients negotiate via `server/discover` and send each request with the +per-request `_meta` envelope. Methods that exist in only one era stay invisible to the other (a 2025-era client asking for a 2026-only method gets a plain `-32601`). `eraSupport: 'modern'` is strict 2026-only. On dual-era instances, read per-request client identity from +`ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for details). A runnable example lives at `examples/server/src/dualEraStdio.ts`, with a two-legged client at +`examples/client/src/dualEraStdioClient.ts`. + ## Server instructions Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to the system prompt. Instructions should not duplicate information already in tool descriptions. diff --git a/examples/client/src/dualEraStdioClient.ts b/examples/client/src/dualEraStdioClient.ts new file mode 100644 index 0000000000..e58bdcdedd --- /dev/null +++ b/examples/client/src/dualEraStdioClient.ts @@ -0,0 +1,68 @@ +/** + * Drives the dual-era stdio server example (`examples/server/src/dualEraStdio.ts`) + * with both kinds of client over a real child-process pipe: + * + * 1. a plain 2025 client — the `initialize` handshake, served exactly as today; + * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the + * `server/discover` probe negotiates the 2026-07-28 revision on the pipe + * (no `initialize` is ever sent), and each modern request carries the + * per-request `_meta` envelope. (Attaching the envelope explicitly is a + * stop-gap: automatic per-request envelope emission is a client-side + * follow-up.) + * + * The client spawns the server example directly from source over stdio: + * + * tsx examples/client/src/dualEraStdioClient.ts + */ +import path from 'node:path'; + +import { Client, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +// Spawn the sibling server example straight from its source (no build step), +// located relative to this file so the demo runs from any working directory. +const SERVER_SOURCE = path.resolve(import.meta.dirname, '../../server/src/dualEraStdio.ts'); +const SERVER = { command: 'npx', args: ['tsx', SERVER_SOURCE] }; + +async function legacyLeg(): Promise { + console.log('--- leg 1: plain 2025 client (initialize handshake) ---'); + const client = new Client({ name: 'legacy-demo-client', version: '1.0.0' }); + await client.connect(new StdioClientTransport(SERVER)); + + console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); + const tools = await client.listTools(); + console.log( + 'tools:', + tools.tools.map(tool => tool.name) + ); + const result = await client.callTool({ name: 'greet', arguments: { name: '2025 client' } }); + console.log('greet result:', JSON.stringify(result.content)); + await client.close(); +} + +async function modernLeg(): Promise { + console.log('--- leg 2: 2026-capable client (server/discover negotiation) ---'); + const client = new Client({ name: 'modern-demo-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StdioClientTransport(SERVER)); + + const negotiated = client.getNegotiatedProtocolVersion(); + console.log('negotiated protocol version:', negotiated); + + // The per-request envelope every 2026-era request carries on the wire. + const envelope = { + [PROTOCOL_VERSION_META_KEY]: negotiated, + [CLIENT_INFO_META_KEY]: { name: 'modern-demo-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + + const result = await client.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: '2026 client' }, _meta: envelope } + }); + console.log('greet result:', JSON.stringify(result.content)); + await client.close(); +} + +await legacyLeg(); +await modernLeg(); +console.log('both legs served by the same dual-era stdio server.'); diff --git a/examples/server/src/dualEraStdio.ts b/examples/server/src/dualEraStdio.ts new file mode 100644 index 0000000000..28009e0bc9 --- /dev/null +++ b/examples/server/src/dualEraStdio.ts @@ -0,0 +1,68 @@ +/** + * Dual-era stdio serving with `eraSupport: 'dual-era'`: one server process, + * one long-lived pipe, both protocol eras. + * + * The same construction backs both legs — nothing about the transport or the + * tool changes per era: + * + * - a plain 2025 client connects with the `initialize` handshake and is served + * exactly as today; + * - a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) negotiates + * the 2026-07-28 revision via `server/discover` on the same pipe and is + * served on the modern era, message by message. + * + * Opting in is the single `eraSupport` option; the default (`'legacy'`) + * preserves today's behavior exactly. + * + * Run with `tsx examples/server/src/dualEraStdio.ts` (or point any stdio MCP + * client at it). `examples/client/src/dualEraStdioClient.ts` drives both legs + * against the built version of this file. + */ +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +// One construction for both legs: tools are defined once and served +// identically to 2025-era and 2026-era clients. +const buildServer = () => { + const server = new McpServer( + { + name: 'dual-era-stdio-server', + version: '1.0.0' + }, + { + capabilities: { tools: {} }, + instructions: 'A small dual-era stdio demo server.', + // The one declared act: serve both protocol eras on this long-lived pipe. + eraSupport: 'dual-era' + } + ); + + server.registerTool( + 'greet', + { + description: 'Greets the caller', + inputSchema: z.object({ name: z.string().describe('Name to greet') }) + }, + async ({ name }): Promise => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + return server; +}; + +const server = buildServer(); +// The transport is unchanged: dual-era support is purely a server-options declaration. +await server.connect(new StdioServerTransport()); +console.error('dual-era stdio server ready (serving 2025-era initialize and 2026-07-28 envelope traffic)'); + +const exit = async () => { + await server.close(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}; + +process.on('SIGINT', exit); +process.on('SIGTERM', exit); diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index a032a10ee4..9ae2950719 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -14,6 +14,7 @@ import type { GetPromptRequest, GetPromptResult, Implementation, + JSONRPCNotification, JSONRPCRequest, JsonSchemaType, JsonSchemaValidator, @@ -49,6 +50,8 @@ import { CreateMessageResultWithToolsSchema, DEFAULT_REQUEST_TIMEOUT_MSEC, DiscoverResultSchema, + isJSONRPCRequest, + isModernProtocolVersion, legacyProtocolVersions, ListChangedOptionsBaseSchema, mergeCapabilities, @@ -275,6 +278,27 @@ export class Client extends Protocol { return ctx; } + /** + * Era-keyed direction enforcement for inbound traffic on channels whose + * transport does not classify (e.g. stdio): the 2026-07-28 era has no + * server→client JSON-RPC request channel — server-to-client interactions + * are carried in-band in `input_required` results — and on stdio the + * client must never write JSON-RPC responses. An inbound request arriving + * on a connection that negotiated a modern era is therefore dropped + * (surfaced via `onerror`) rather than answered. Connections on a legacy + * era — and all responses and notifications — keep today's dispatch path. + */ + protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { + if ( + this._negotiatedProtocolVersion !== undefined && + isModernProtocolVersion(this._negotiatedProtocolVersion) && + isJSONRPCRequest(message) + ) { + return 'drop'; + } + return undefined; + } + /** * Set up handlers for list changed notifications based on config and server capabilities. * This should only be called after initialization when server capabilities are known. diff --git a/packages/client/test/client/modernEraInboundDrop.test.ts b/packages/client/test/client/modernEraInboundDrop.test.ts new file mode 100644 index 0000000000..c23f5d1614 --- /dev/null +++ b/packages/client/test/client/modernEraInboundDrop.test.ts @@ -0,0 +1,109 @@ +/** + * TS-01 directionality, client side: the 2026-07-28 era has no server→client + * JSON-RPC request channel, and on stdio the client must never write JSON-RPC + * responses — so an inbound request arriving on a connection that negotiated + * a modern era is dropped (surfaced via `onerror`), never answered. Legacy-era + * connections keep today's behavior (the client answers, e.g. with −32601 for + * methods it has no handler for). + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +const flush = () => new Promise(resolve => setTimeout(resolve, 20)); + +/** + * A scripted server side of an in-memory pair: answers `server/discover` (so a + * negotiating client lands on the modern era) or `initialize` (legacy era), and + * records everything the client writes. + */ +async function scriptedServerSide(eras: 'modern' | 'legacy') { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const request = message as { id?: number | string; method?: string }; + if (request.method === 'server/discover' && request.id !== undefined) { + if (eras === 'modern') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } + } + }); + } else { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + error: { code: -32_601, message: 'Method not found' } + }); + } + return; + } + if (request.method === 'initialize' && request.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' } + } + }); + } + }; + await serverTx.start(); + return { clientTx, serverTx, written }; +} + +describe('client inbound-drop on modern-era connections (TS-01)', () => { + it('drops an inbound server→client request without writing any response, surfacing it via onerror', async () => { + const { clientTx, serverTx, written } = await scriptedServerSide('modern'); + const client = new Client({ name: 'drop-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const errors: Error[] = []; + client.onerror = error => void errors.push(error); + await client.connect(clientTx); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const before = written.length; + // A misbehaving "modern" server sends a server→client request (the + // channel is deleted in the 2026 era). The client must not answer. + await serverTx.send({ + jsonrpc: '2.0', + id: 'rogue-1', + method: 'roots/list', + params: {} + }); + await flush(); + + expect(written).toHaveLength(before); + expect(errors.some(error => error.message.includes('Dropped inbound request'))).toBe(true); + + await client.close(); + }); + + it('keeps answering inbound requests on legacy-era connections (control arm)', async () => { + const { clientTx, serverTx, written } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'legacy-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await serverTx.send({ jsonrpc: '2.0', id: 'legacy-1', method: 'roots/list', params: {} }); + await flush(); + + // Today's behavior: the client answers (here −32601, no roots handler installed). + const answer = written.find(message => (message as { id?: string }).id === 'legacy-1'); + expect(answer).toBeDefined(); + expect((answer as { error?: { code: number } }).error?.code).toBe(-32_601); + + await client.close(); + }); +}); diff --git a/packages/client/test/client/probeFixtureCorpus.test.ts b/packages/client/test/client/probeFixtureCorpus.test.ts new file mode 100644 index 0000000000..2e46b5faea --- /dev/null +++ b/packages/client/test/client/probeFixtureCorpus.test.ts @@ -0,0 +1,231 @@ +/** + * Merged first-contact fixture corpus (T9 probe edges ∪ wire-real shapes) + * binding the two pure modules of the negotiation path: + * + * - the probe-outcome classifier (`classifyProbeOutcome`): the five T9 probe + * edges (plain-text 400; JSON-RPC `code: 0`; probe-success-then-no-overlap + * → initialize on the SAME connection; legacy servers that 200-process + * era-ambiguous first requests; numeric-id collision avoidance via a string + * probe id) merged with the wire-real first-contact shapes a deployed 2025 + * TypeScript server actually answers (the −32000 "Unsupported protocol + * version" literal and the 400/−32000 session-required body). Recognition + * is a typed allowlist — codes and structured data — never message-text + * sniffing. + * - the era predicate's per-message form is bound by + * `packages/core/test/shared/classifyInboundMessage.test.ts` (T11). + * + * Probe RUNTIME (timeout/retry policy and the connect loop) is covered by the + * negotiation engine suites; this corpus pins classification only, plus the + * probe wire shape (string id, `server/discover` first, never a real request). + */ +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { ProbeClassifierContext, ProbeOutcome, ProbeVerdict } from '../../src/client/probeClassifier.js'; +import { classifyProbeOutcome } from '../../src/client/probeClassifier.js'; + +const MODERN = '2026-07-28'; + +const baseContext: ProbeClassifierContext = { + clientModernVersions: [MODERN], + requestedVersion: MODERN, + fallbackAvailable: true, + environment: 'node', + transportKind: 'stdio' +}; + +/** The byte-exact first-contact literal a deployed 2025 stateless server answers a modern probe with. */ +const DEPLOYED_UNSUPPORTED_VERSION_BODY = JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { + code: -32_000, + message: `Bad Request: Unsupported protocol version: ${MODERN} (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)` + } +}); + +/** The session-required free-text shape a deployed stateful server answers a session-less probe with. */ +const DEPLOYED_SESSION_REQUIRED_BODY = JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { code: -32_000, message: 'Bad Request: Server not initialized' } +}); + +interface CorpusRow { + name: string; + outcome: ProbeOutcome; + context?: Partial; + expected: ProbeVerdict['kind']; +} + +const CORPUS: CorpusRow[] = [ + // --- T9 edge 1: plain-text 400 (no JSON-RPC body at all). + { + name: 'T9: plain-text HTTP 400 → legacy fallback', + outcome: { kind: 'http-error', status: 400, body: 'Bad Request' }, + expected: 'legacy' + }, + // --- T9 edge 2: JSON-RPC error with code 0. + { + name: 'T9: JSON-RPC error code 0 → legacy fallback', + outcome: { kind: 'rpc-error', code: 0, message: 'unknown method' }, + expected: 'legacy' + }, + // --- T9 edge 3: probe success but no version overlap → initialize on the SAME connection. + { + name: 'T9: DiscoverResult with no mutual version + fallback available → legacy (initialize on the same connection)', + outcome: { + kind: 'result', + result: { supportedVersions: ['2027-01-01'], capabilities: {}, serverInfo: { name: 's', version: '1' } } + }, + expected: 'legacy' + }, + { + name: 'T9: DiscoverResult with no mutual version + NO fallback (pin / modern-only) → typed error, never initialize', + outcome: { + kind: 'result', + result: { supportedVersions: ['2027-01-01'], capabilities: {}, serverInfo: { name: 's', version: '1' } } + }, + context: { fallbackAvailable: false }, + expected: 'error' + }, + // --- T9 edge 4: a legacy server that 200-processes an era-ambiguous first request. + // The probe is server/discover precisely so this comes back as an + // unrecognized result shape (never a DiscoverResult) and stays legacy. + { + name: 'T9: 200-processed era-ambiguous result (not a DiscoverResult) → legacy fallback', + outcome: { kind: 'result', result: { tools: [{ name: 'echo', inputSchema: { type: 'object' } }] } }, + expected: 'legacy' + }, + // --- Wire-real shape A: the deployed −32000 unsupported-protocol-version literal (HTTP 400). + { + name: 'wire-real: HTTP 400 with the deployed -32000 "Unsupported protocol version" literal → legacy fallback', + outcome: { kind: 'http-error', status: 400, body: DEPLOYED_UNSUPPORTED_VERSION_BODY }, + expected: 'legacy' + }, + // --- Wire-real shape B: the deployed 400/−32000 session-required free text. + { + name: 'wire-real: HTTP 400 with the deployed -32000 session-required body → legacy fallback', + outcome: { kind: 'http-error', status: 400, body: DEPLOYED_SESSION_REQUIRED_BODY }, + expected: 'legacy' + }, + // --- Typed-recognizer allowlist: text never upgrades, codes + structured data decide. + { + name: 'recognizer: -32601 whose message merely CONTAINS "Unsupported protocol version" is not modern evidence → legacy', + outcome: { kind: 'rpc-error', code: -32_601, message: `Unsupported protocol version: ${MODERN}` }, + expected: 'legacy' + }, + { + name: 'recognizer: -32004 with a structured supported list naming a mutual modern version → corrective continuation', + outcome: { + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN, LATEST_PROTOCOL_VERSION], requested: '2027-01-01' } + }, + expected: 'corrective' + }, + { + name: 'recognizer: -32004 without a parsable data.supported list is not actionable modern evidence → legacy', + outcome: { kind: 'rpc-error', code: -32_004, message: 'Unsupported protocol version' }, + expected: 'legacy' + }, + { + name: 'recognizer: -32004 with a legacy-only supported list is a definitive legacy signal → legacy', + outcome: { + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [LATEST_PROTOCOL_VERSION], requested: MODERN } + }, + expected: 'legacy' + }, + { + name: 'recognizer: a 200 result that merely mentions supportedVersions in a text field is not a DiscoverResult → legacy', + outcome: { kind: 'result', result: { content: [{ type: 'text', text: `supportedVersions: ["${MODERN}"]` }] } }, + expected: 'legacy' + }, + // --- Q12 transport-aware timeout rows (stdio falls back, HTTP stays a typed error). + { + name: 'timeout on stdio → legacy fallback (the stdio backward-compatibility rule)', + outcome: { kind: 'timeout', timeoutMs: 500 }, + expected: 'legacy' + }, + { + name: 'timeout on HTTP → typed connect error, never an era verdict', + outcome: { kind: 'timeout', timeoutMs: 500 }, + context: { transportKind: 'http' }, + expected: 'error' + }, + // --- -32601 from a deployed legacy server (the common pre-initialize answer). + { + name: 'wire-real: -32601 method-not-found → legacy fallback', + outcome: { kind: 'rpc-error', code: -32_601, message: 'Method not found' }, + expected: 'legacy' + } +]; + +describe('T9/T11 merged probe fixture corpus (probe classifier)', () => { + for (const row of CORPUS) { + it(row.name, () => { + const verdict = classifyProbeOutcome(row.outcome, { ...baseContext, ...row.context }); + expect(verdict.kind).toBe(row.expected); + }); + } + + it('a DiscoverResult with a mutual version is the only result shape that yields a modern verdict', () => { + const verdict = classifyProbeOutcome( + { + kind: 'result', + result: { supportedVersions: [MODERN], capabilities: {}, serverInfo: { name: 's', version: '1' } } + }, + baseContext + ); + expect(verdict.kind).toBe('modern'); + if (verdict.kind === 'modern') { + expect(verdict.version).toBe(MODERN); + } + }); +}); + +describe('T9 edge 5: probe wire shape (string probe id on the shared pipe)', () => { + it('probes with server/discover before any real request, using a string request id and the protocol-version envelope key', async () => { + const written: JSONRPCMessage[] = []; + // A scripted silent-legacy transport: records what the client writes and + // never answers, so only the probe (and, after its timeout, the + // initialize fallback) ever reaches the wire. + const transport: Transport = { + async start() {}, + async close() {}, + async send(message) { + written.push(message); + } + }; + + const client = new Client( + { name: 'probe-shape-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 50 } } } + ); + // The silent transport also never answers initialize; the connect + // attempt eventually fails — the probe wire shape is what this pin is + // about. + await client.connect(transport, { timeout: 200 }).catch(() => {}); + + expect(written.length).toBeGreaterThan(0); + const probe = written[0] as { id?: unknown; method?: string; params?: { _meta?: Record } }; + expect(probe.method).toBe('server/discover'); + // String probe id: the probe runs above the Protocol layer on the same + // shared pipe, so it must never collide with the numeric ids Protocol + // assigns to real requests. + expect(typeof probe.id).toBe('string'); + expect(probe.params?._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + // Never probe with the first real request: nothing other than the probe + // and the legacy initialize fallback is written during connect. + for (const message of written) { + const method = (message as { method?: string }).method; + expect(['server/discover', 'initialize', 'notifications/initialized']).toContain(method); + } + }); +}); diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index f731796280..247567516d 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -617,6 +617,50 @@ export function classifyInboundRequest(request: InboundHttpRequest): InboundClas ); } +/* ------------------------------------------------------------------------ * + * Per-message classification (long-lived channels) + * ------------------------------------------------------------------------ */ + +/** + * Classifies one inbound JSON-RPC message for a long-lived dual-era channel + * (stdio and other hand-wired transports with no HTTP edge): the body-primary + * predicate reduced to its per-message form — there is no header layer (the + * stdio transport carries all request metadata inline in the message body) + * and no HTTP method to route. + * + * - `initialize` is the legacy handshake by definition; the version it + * requested is carried as the classification's `revision`. + * - A message whose `params._meta` carries the reserved protocol-version key + * claims the per-request envelope mechanism and classifies into the era of + * the named revision. Envelope validity is enforced at dispatch by the era + * codec — a malformed envelope behind a present claim is a validation + * error, never a silent fall back to legacy handling. + * - A message without that claim — including one carrying only + * `progressToken` or other non-reserved `_meta` keys — is legacy-era + * traffic. + * + * Pure and total over requests and notifications; consumed by the + * protocol-layer classification consult for dual-era server instances. + */ +export function classifyInboundMessage(message: { method: string; params?: unknown }): MessageClassification { + if (message.method === 'initialize') { + const params = message.params; + const requestedVersion = + isPlainObject(params) && typeof params['protocolVersion'] === 'string' ? params['protocolVersion'] : undefined; + // The classification's `revision` names the wire revision the message + // is classified INTO, so it only carries the requested version when + // that version is itself a legacy one — an `initialize` requesting a + // modern revision is still the legacy handshake (it never negotiates + // a modern era) and stays a bare legacy classification. + const legacyRevision = requestedVersion !== undefined && !isModernProtocolVersion(requestedVersion) ? requestedVersion : undefined; + return { era: 'legacy', ...(legacyRevision !== undefined && { revision: legacyRevision }) }; + } + if (hasEnvelopeClaim(message.params)) { + return classificationForClaim(envelopeClaimVersion(message.params)); + } + return { era: 'legacy' }; +} + /* ------------------------------------------------------------------------ * * Modern-only (strict) mapping of legacy routes * ------------------------------------------------------------------------ */ diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index bf2e5b95b9..d2335a6065 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -15,6 +15,7 @@ import type { JSONRPCResponse, JSONRPCResultResponse, LoggingLevel, + MessageClassification, MessageExtraInfo, Notification, NotificationMethod, @@ -488,6 +489,28 @@ export abstract class Protocol { */ protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; + /** + * Classification consult for inbound messages whose transport did not + * classify them at the edge — long-lived dual-era channels such as stdio, + * where the protocol era is decided per message rather than per request + * at an HTTP edge. + * + * Consulted ONLY when the transport supplied no + * {@linkcode MessageExtraInfo.classification}: an edge classification + * always wins and the hook is never reached for it. The returned + * classification populates the carrier; on an instance with no negotiated + * protocol version it also selects the wire era for this one message, + * while an instance bound to a negotiated version validates it exactly + * like an edge classification (a mismatch is the typed + * unsupported-protocol-version answer for requests, a drop for + * notifications). Returning `'drop'` discards the message without writing + * any response. The base implementation returns `undefined`: unclassified + * traffic keeps today's dispatch path unchanged. + */ + protected _classifyInbound(_message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { + return undefined; + } + private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { return; @@ -627,9 +650,27 @@ export abstract class Protocol { // Era is instance state: the negotiated protocol version selects the // codec for everything this connection receives (legacy until - // negotiated). Classification is no longer a per-message era switch — - // it is validated against the instance era below. - const codec = this._negotiatedWireCodec(); + // negotiated). An edge classification is never a per-message era + // switch — it is validated against the instance era below. + let codec = this._negotiatedWireCodec(); + + // Classification consult (only when the transport did not classify; + // an edge classification always wins and never reaches the hook). On + // an unbound instance the hook's classification selects the era for + // this one message (long-lived dual-era channels); a bound instance + // validates it below exactly like an edge classification. + if (extra?.classification === undefined) { + const consulted = this._classifyInbound(rawNotification); + if (consulted === 'drop') { + return; + } + if (consulted !== undefined) { + extra = { ...extra, classification: consulted }; + if (this._negotiatedProtocolVersion === undefined) { + codec = codecForVersion(classifiedWireEra(consulted)); + } + } + } // Edge→instance handoff check: a classification that disagrees with // the instance era means the entry routed another era's traffic onto @@ -679,11 +720,30 @@ export abstract class Protocol { // Era is instance state: the negotiated protocol version selects the // codec for everything this connection receives (legacy until - // negotiated). Classification (Q2; this layer only CONSUMES - // MessageExtraInfo.classification) is no longer a per-message era - // switch — it is validated against the instance era below. Hand-wired - // legacy transports never classify, so their behavior is untouched. - const codec = this._negotiatedWireCodec(); + // negotiated). An edge classification (Q2; produced at the HTTP + // entry) is never a per-message era switch — it is validated against + // the instance era below. Hand-wired legacy transports never + // classify, so their behavior is untouched. + let codec = this._negotiatedWireCodec(); + + // Classification consult (only when the transport did not classify; + // an edge classification always wins and never reaches the hook). On + // an unbound instance the hook's classification selects the era for + // this one message (long-lived dual-era channels); a bound instance + // validates it below exactly like an edge classification. + if (extra?.classification === undefined) { + const consulted = this._classifyInbound(rawRequest); + if (consulted === 'drop') { + this._onerror(new Error(`Dropped inbound request '${rawRequest.method}': not servable on this connection's protocol era`)); + return; + } + if (consulted !== undefined) { + extra = { ...extra, classification: consulted }; + if (this._negotiatedProtocolVersion === undefined) { + codec = codecForVersion(classifiedWireEra(consulted)); + } + } + } // Capture the current transport at request time to ensure responses go to the correct client const capturedTransport = this._transport; diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 9af48586c2..fced9eb501 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -618,7 +618,10 @@ export type ListChangedHandlers = { * `Client`/`Server` instance); the protocol layer validates a classified * message against that instance era at dispatch — a mismatch is treated as * an entry/routing error, never a per-message era switch. Unclassified - * traffic is dispatched on the instance era unchanged. + * traffic is dispatched on the instance era unchanged, except on long-lived + * dual-era channels (e.g. a stdio server that declared dual-era support), + * where the protocol layer's own classification consult classifies each + * message and selects its era per message. */ export interface MessageClassification { /** @@ -646,7 +649,9 @@ export interface MessageExtraInfo { * Protocol-era classification of the message, when the transport * classified it at the edge. Validated by the protocol layer against the * instance's negotiated era at dispatch (the edge→instance handoff - * check); it does not select the era itself. + * check); an edge classification never selects the era itself. When the + * transport did not classify, the protocol layer's classification consult + * may populate this carrier per message (long-lived dual-era channels). */ classification?: MessageClassification; diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index 586cb2e044..72e13e3634 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -173,12 +173,14 @@ export function codecForVersion(version: string | undefined): WireCodec { } /** - * The wire era an edge classification names (Q2 — produced at the - * transport/entry edge; this layer only CONSUMES it). The dispatch funnel no - * longer resolves a codec FROM the classification: era is instance state, and - * a classified inbound message is VALIDATED against the instance era — a - * mismatch is an entry/routing error, never a per-message era switch. The - * exact `revision` wins over the coarse era flag when both are present. + * The wire era a classification names (Q2 — produced at the transport/entry + * edge or, for long-lived dual-era channels, by the protocol layer's own + * per-message classification consult). For edge classifications the dispatch + * funnel never resolves a codec FROM the classification: era is instance + * state, and the classified message is VALIDATED against it — a mismatch is + * an entry/routing error. Only an unbound dual-era instance selects the + * message's codec from its classification (per-message era). The exact + * `revision` wins over the coarse era flag when both are present. */ export function classifiedWireEra(classification: MessageClassification): WireEra { if (classification.revision !== undefined) return codecForVersion(classification.revision).era; diff --git a/packages/core/test/shared/classifyInboundMessage.test.ts b/packages/core/test/shared/classifyInboundMessage.test.ts new file mode 100644 index 0000000000..1b86f690df --- /dev/null +++ b/packages/core/test/shared/classifyInboundMessage.test.ts @@ -0,0 +1,107 @@ +/** + * Per-message era predicate for long-lived dual-era channels + * (`classifyInboundMessage`) — the body-primary rule (Q2) in its stdio form, + * with the T11 sharpening: classification keys on the SPECIFIC reserved + * envelope key (`io.modelcontextprotocol/protocolVersion`), never on bare + * `_meta` presence. + */ +import { describe, expect, it } from 'vitest'; + +import { classifyInboundMessage } from '../../src/shared/inboundClassification.js'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY +} from '../../src/types/index.js'; + +const MODERN = '2026-07-28'; + +const fullEnvelope = (version: string) => ({ + [PROTOCOL_VERSION_META_KEY]: version, + [CLIENT_INFO_META_KEY]: { name: 'fixture-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}); + +describe('classifyInboundMessage (per-message body-primary predicate)', () => { + it('classifies `initialize` as legacy and carries the requested version as the revision', () => { + const classification = classifyInboundMessage({ + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } } + }); + expect(classification).toEqual({ era: 'legacy', revision: '2025-06-18' }); + }); + + it('classifies `initialize` without a parsable protocolVersion as legacy with no revision', () => { + expect(classifyInboundMessage({ method: 'initialize', params: {} })).toEqual({ era: 'legacy' }); + expect(classifyInboundMessage({ method: 'initialize' })).toEqual({ era: 'legacy' }); + }); + + it('classifies `initialize` REQUESTING a modern revision as a bare legacy classification (initialize never negotiates a modern era)', () => { + const classification = classifyInboundMessage({ + method: 'initialize', + params: { protocolVersion: MODERN, capabilities: {}, clientInfo: { name: 'c', version: '1' } } + }); + expect(classification).toEqual({ era: 'legacy' }); + }); + + it('classifies a message carrying the reserved protocol-version envelope key as modern with the claimed revision', () => { + const classification = classifyInboundMessage({ + method: 'tools/list', + params: { _meta: fullEnvelope(MODERN) } + }); + expect(classification).toEqual({ era: 'modern', revision: MODERN }); + }); + + it('classifies an envelope claim naming a 2025-era revision as legacy with that revision', () => { + const classification = classifyInboundMessage({ + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: '2025-06-18' } } + }); + expect(classification).toEqual({ era: 'legacy', revision: '2025-06-18' }); + }); + + it('classifies a claim with a non-string protocol-version value as a modern claim (validated at dispatch, never silently legacy)', () => { + const classification = classifyInboundMessage({ + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } } + }); + expect(classification).toEqual({ era: 'modern' }); + }); + + it('T11: a legacy client carrying only `progressToken` in `_meta` classifies legacy — never bare `_meta` presence', () => { + const classification = classifyInboundMessage({ + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: { progressToken: 7 } } + }); + expect(classification).toEqual({ era: 'legacy' }); + }); + + it('T11: other reserved envelope keys without the protocol-version key do NOT constitute a claim', () => { + const classification = classifyInboundMessage({ + method: 'tools/call', + params: { + name: 'echo', + arguments: {}, + _meta: { + [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, + [CLIENT_CAPABILITIES_META_KEY]: {}, + [LOG_LEVEL_META_KEY]: 'info' + } + } + }); + expect(classification).toEqual({ era: 'legacy' }); + }); + + it('classifies a claim-less request as legacy', () => { + expect(classifyInboundMessage({ method: 'tools/list', params: {} })).toEqual({ era: 'legacy' }); + expect(classifyInboundMessage({ method: 'ping' })).toEqual({ era: 'legacy' }); + }); + + it('classifies notifications by the same body-primary rule', () => { + expect(classifyInboundMessage({ method: 'notifications/cancelled', params: { requestId: 1 } })).toEqual({ era: 'legacy' }); + expect( + classifyInboundMessage({ method: 'notifications/cancelled', params: { requestId: 1, _meta: fullEnvelope(MODERN) } }) + ).toEqual({ era: 'modern', revision: MODERN }); + }); +}); diff --git a/packages/core/test/shared/protocolClassifyInboundHook.test.ts b/packages/core/test/shared/protocolClassifyInboundHook.test.ts new file mode 100644 index 0000000000..23bb7cf6ce --- /dev/null +++ b/packages/core/test/shared/protocolClassifyInboundHook.test.ts @@ -0,0 +1,255 @@ +/** + * The protocol-layer classification consult (`Protocol._classifyInbound`): + * + * - B-2 pin: when the transport supplied an edge classification, the hook is + * NEVER consulted — the edge classification always wins. + * - The base implementation returns `undefined`, so unclassified traffic on + * a default instance keeps today's dispatch path byte-identically. + * - A hook classification populates the `MessageExtraInfo.classification` + * carrier and, on an UNBOUND instance (no negotiated protocol version), + * selects the wire era for that one message (per-message era on long-lived + * dual-era channels). On a BOUND instance it is validated exactly like an + * edge classification (mismatch ⇒ −32004 for requests, drop for + * notifications). + * - Returning `'drop'` discards the message without writing any response. + */ +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse, + MessageClassification, + MessageExtraInfo, + Result +} from '../../src/types/index.js'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + PROTOCOL_VERSION_META_KEY +} from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +const MODERN = '2026-07-28'; + +const modernEnvelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'hook-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +class HookedProtocol extends Protocol { + /** Messages the hook was consulted for (in order). */ + consulted: Array = []; + /** What the hook answers; `undefined` keeps the base behavior. */ + verdict: ((message: JSONRPCRequest | JSONRPCNotification) => MessageClassification | 'drop' | undefined) | undefined; + /** The MessageExtraInfo handed to buildContext for the last dispatched request. */ + lastExtra: MessageExtraInfo | undefined; + + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): BaseContext { + this.lastExtra = transportInfo; + return ctx; + } + + protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { + this.consulted.push(message); + return this.verdict?.(message); + } +} + +class BaseProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + +async function wire>(protocol: T) { + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + const errors: Error[] = []; + protocol.onerror = error => void errors.push(error); + await protocol.connect(protocolTx); + return { peerTx, protocolTx, sent, errors }; +} + +describe('B-2: an edge classification always wins', () => { + it('never consults the hook for a message that already carries a classification', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => ({ era: 'modern', revision: MODERN }); + const { protocolTx, sent } = await wire(protocol); + + protocolTx.onmessage?.( + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage, + // The in-memory transport's onmessage declares the narrower + // pre-classification extra type; the protocol layer reads the + // full MessageExtraInfo (same cast as the era-gate suite). + { classification: { era: 'legacy' } } as never + ); + await flush(); + + expect(protocol.consulted).toHaveLength(0); + // The edge classification (legacy) matches the unbound instance era, + // so the request proceeds to today's path: no handler ⇒ −32601. + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error.code).toBe(-32_601); + await protocol.close(); + }); + + it('consults the hook when the transport did not classify', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => undefined; + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + await flush(); + + expect(protocol.consulted).toHaveLength(1); + expect(protocol.consulted[0]).toMatchObject({ method: 'tools/list' }); + // `undefined` keeps today's path: no handler ⇒ −32601, no classification carrier. + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error.code).toBe(-32_601); + await protocol.close(); + }); +}); + +describe("base implementation (no override) keeps today's dispatch", () => { + it('serves unclassified legacy traffic identically: handler runs, result is not stamped with 2026 wire fields', async () => { + const protocol = new BaseProtocol(); + protocol.setRequestHandler('tools/list', () => ({ tools: [] })); + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + const response = sent[0] as JSONRPCResultResponse; + expect(isJSONRPCResultResponse(response)).toBe(true); + expect(response.result).toEqual({ tools: [] }); + expect(JSON.stringify(response)).not.toContain('resultType'); + await protocol.close(); + }); +}); + +describe('per-message era on an unbound instance (long-lived dual-era channels)', () => { + it('a hook classification of modern serves the message on the 2026 era: envelope honored, result stamped', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = message => (message.method === 'initialize' ? { era: 'legacy' } : { era: 'modern', revision: MODERN }); + protocol.setRequestHandler('tools/list', () => ({ tools: [] })); + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: modernEnvelope } }); + await flush(); + + expect(sent).toHaveLength(1); + const response = sent[0] as JSONRPCResultResponse; + expect(isJSONRPCResultResponse(response)).toBe(true); + expect((response.result as { resultType?: string }).resultType).toBe('complete'); + // The carrier was populated and reached the handler context. + expect(protocol.lastExtra?.classification).toEqual({ era: 'modern', revision: MODERN }); + await protocol.close(); + }); + + it('a hook classification of legacy answers a 2026-only spec method with a plain −32601 (era gate by registry absence)', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => ({ era: 'legacy' }); + // Even an installed handler cannot shadow the era gate. + protocol.setRequestHandler('server/discover', { params: z.looseObject({}) }, () => ({}) as Result); + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 3, method: 'server/discover', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + const response = sent[0] as JSONRPCErrorResponse; + expect(isJSONRPCErrorResponse(response)).toBe(true); + expect(response.error).toEqual({ code: -32_601, message: 'Method not found' }); + await protocol.close(); + }); +}); + +describe('hook classification on a BOUND instance is validated like an edge classification', () => { + it('a legacy-classified request on a modern-bound instance answers −32004 with the supported list', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => ({ era: 'legacy' }); + const { peerTx, sent } = await wire(protocol); + setNegotiatedProtocolVersion(protocol, MODERN); + + await peerTx.send({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + const error = (sent[0] as JSONRPCErrorResponse).error as { code: number; data?: { supported?: string[] } }; + expect(error.code).toBe(-32_004); + expect(Array.isArray(error.data?.supported)).toBe(true); + await protocol.close(); + }); + + it('a legacy-classified notification on a modern-bound instance is dropped (no handler invocation, no response)', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => ({ era: 'legacy' }); + let invoked = 0; + protocol.fallbackNotificationHandler = async () => { + invoked += 1; + }; + const { peerTx, sent, errors } = await wire(protocol); + setNegotiatedProtocolVersion(protocol, MODERN); + + await peerTx.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await flush(); + + expect(invoked).toBe(0); + expect(sent).toHaveLength(0); + expect(errors.length).toBeGreaterThan(0); + await protocol.close(); + }); +}); + +describe("'drop' verdict", () => { + it('discards an inbound request without writing any response and surfaces it via onerror', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => 'drop'; + protocol.setRequestHandler('tools/list', () => ({ tools: [] })); + const { peerTx, sent, errors } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: {} }); + await flush(); + + expect(sent).toHaveLength(0); + expect(errors.some(error => error.message.includes('Dropped inbound request'))).toBe(true); + await protocol.close(); + }); + + it('discards an inbound notification without dispatching it', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => 'drop'; + let invoked = 0; + protocol.fallbackNotificationHandler = async () => { + invoked += 1; + }; + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await flush(); + + expect(invoked).toBe(0); + expect(sent).toHaveLength(0); + await protocol.close(); + }); +}); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index fbbafb50c9..78daf3d5a8 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -14,6 +14,7 @@ import type { Implementation, InitializeRequest, InitializeResult, + JSONRPCNotification, JSONRPCRequest, JsonSchemaType, jsonSchemaValidator, @@ -21,6 +22,7 @@ import type { ListRootsResult, LoggingLevel, LoggingMessageNotification, + MessageClassification, MessageExtraInfo, NotificationMethod, NotificationOptions, @@ -35,9 +37,14 @@ import type { ToolUseContent } from '@modelcontextprotocol/core'; import { + classifyInboundMessage, codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, + envelopeClaimVersion, + FIRST_MODERN_PROTOCOL_VERSION, + hasEnvelopeClaim, + isModernProtocolVersion, LATEST_PROTOCOL_VERSION, legacyProtocolVersions, LoggingLevelSchema, @@ -47,10 +54,14 @@ import { Protocol, ProtocolError, ProtocolErrorCode, + requestMetaOf, SdkError, - SdkErrorCode + SdkErrorCode, + SUPPORTED_MODERN_PROTOCOL_VERSIONS, + validateEnvelopeMeta } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +import * as z from 'zod/v4'; export type ServerOptions = ProtocolOptions & { /** @@ -79,8 +90,88 @@ export type ServerOptions = ProtocolOptions & { * @default Runtime-selected validator (AJV-backed on Node.js, `@cfworker/json-schema`-backed on browser/workerd runtimes) */ jsonSchemaValidator?: jsonSchemaValidator; + + /** + * Which protocol eras this server serves on its long-lived connection + * (e.g. stdio): the 2025-era `initialize` family, the 2026-07-28 + * per-request-envelope revision, or both. + * + * - `'legacy'` (the default) preserves exactly what existing code was + * written for: the server speaks the 2025-era protocol negotiated via + * `initialize`, never registers or advertises `server/discover`, and + * upgrading the SDK changes nothing about what the instance puts on the + * wire. + * - `'dual-era'` serves BOTH eras on the same connection, selecting the + * era per message: `initialize`-negotiated 2025 traffic is served as + * before, while messages carrying the 2026-07-28 per-request `_meta` + * envelope (including `server/discover`) are served on the modern era. + * Declaring dual-era support is an explicit act — the consumer asserts + * that the server is ready to serve modern-era requests. + * - `'modern'` is strict 2026-07-28-only: requests without the + * per-request envelope (including `initialize`) are answered with the + * unsupported-protocol-version error naming the supported revisions. + * + * Declaring `'dual-era'` or `'modern'` automatically adds the SDK's + * supported modern revisions to + * {@linkcode ProtocolOptions.supportedProtocolVersions}, and `'modern'` + * serves only those: a strict instance's supported-versions list (what + * `server/discover` advertises and version-mismatch errors name) is its + * modern subset. + * + * Opting in is one option away and the transport stays unchanged: + * + * ```ts + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); + * await server.connect(new StdioServerTransport()); + * ``` + * + * A 2026-era revision in {@linkcode ProtocolOptions.supportedProtocolVersions} + * requires `'dual-era'` or `'modern'`; passing one on a (default) + * `'legacy'` instance throws a `TypeError` at construction. + * + * Per-request HTTP serving via `createMcpHandler` does not use this + * option: the entry classifies each request and binds the per-request + * instance itself. + * + * @default 'legacy' + */ + eraSupport?: 'legacy' | 'dual-era' | 'modern'; }; +/** + * Permissive params schema for the `server/discover` registration on servers + * that declared modern-era support. The discover request carries only the + * per-request `_meta` envelope, which the protocol layer lifts and validates + * before dispatch — and a long-lived dual-era instance is never bound to a + * single era, so the spec-method registration form (which resolves its + * dispatch schema from the instance era) cannot be used here. + */ +const DISCOVER_PARAMS_SCHEMA = z.looseObject({}); + +/** + * Whether a message's params carry a per-request envelope claim that is both + * well-formed and names a modern protocol revision. + * + * The per-message form of the inbound classifier's `initialize` precedence + * rule: only such a claim overrides the `initialize` ⇒ legacy-handshake + * classification — a message carrying a valid modern envelope is a modern + * request regardless of its method name, and the modern era then answers + * `initialize` exactly like any other method it does not define + * (method-not-found). A malformed claim, or one naming a pre-2026 revision, + * keeps the legacy-handshake routing unchanged. + */ +function carriesValidModernEnvelopeClaim(params: unknown): boolean { + if (!hasEnvelopeClaim(params)) { + return false; + } + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion === undefined || !isModernProtocolVersion(claimedVersion)) { + return false; + } + const meta = requestMetaOf(params); + return meta !== undefined && validateEnvelopeMeta(meta).length === 0; +} + /* * Package-internal hooks for the per-request (2026-07-28) HTTP serving entry. * @@ -159,6 +250,14 @@ export class Server extends Protocol { private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; + private _eraSupport: 'legacy' | 'dual-era' | 'modern'; + /** + * The protocol version a legacy `initialize` handshake negotiated on a + * dual-era instance. A dual-era instance is never bound to a single era + * (the era is selected per message), so the handshake result is recorded + * here only for the initialize-scoped accessor. + */ + private _dualEraInitializeVersion?: string; /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -176,14 +275,50 @@ export class Server extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this._eraSupport = options?.eraSupport ?? 'legacy'; this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); - // server/discover is installed only when the supported-versions list - // carries a modern revision: a legacy-only server keeps answering -32601. - if (modernProtocolVersions(this._supportedProtocolVersions).length > 0) { - this.setRequestHandler('server/discover', () => this._ondiscover()); + if (this._eraSupport === 'legacy') { + // The default preserves exactly what the code was written for: + // 2025-era serving only, nothing 2026-era registered or + // advertised. Serving a 2026-era revision is a declared act — a + // modern revision in the supported list without that declaration + // is a configuration error, never a silent behavior change. + const modernVersions = modernProtocolVersions(this._supportedProtocolVersions); + if (modernVersions.length > 0) { + throw new TypeError( + `supportedProtocolVersions contains the protocol revision ${modernVersions[0]}, which this server does not serve ` + + `with the default eraSupport of 'legacy'. Declare { eraSupport: 'dual-era' } (serve both eras) or ` + + `{ eraSupport: 'modern' } (2026-era only) to serve it.` + ); + } + } else { + // server/discover is registered (and modern revisions advertised) + // only on servers that declared modern-era support; the served + // modern revisions are added to the supported list so the + // advertisement and version-mismatch errors name them (a new + // array — the shared default constant is never mutated). + const missing = SUPPORTED_MODERN_PROTOCOL_VERSIONS.filter(version => !this._supportedProtocolVersions.includes(version)); + if (missing.length > 0) { + this._supportedProtocolVersions = [...this._supportedProtocolVersions, ...missing]; + } + this.setRequestHandler('server/discover', { params: DISCOVER_PARAMS_SCHEMA }, () => this._ondiscover()); + if (this._eraSupport === 'modern') { + // A strict modern-only server serves only modern revisions, so + // the supported list is reduced to its modern subset — keeping + // the legacy entries would advertise revisions the instance + // never serves in the unsupported-protocol-version error's + // supported list, and `initialize` (the only other consumer of + // the legacy entries) is unreachable on a strict instance. + this._supportedProtocolVersions = modernProtocolVersions(this._supportedProtocolVersions); + // A strict modern-only server is bound to the modern era from + // construction: requests classified into the 2025 era are + // answered with the typed unsupported-protocol-version error + // naming the supported revisions, never served. + this._negotiatedProtocolVersion = this._supportedProtocolVersions[0]; + } } if (this._capabilities.logging) { @@ -191,6 +326,35 @@ export class Server extends Protocol { } } + /** + * Per-message era classification for long-lived dual-era channels (e.g. a + * stdio server that declared modern-era support). Active only when the + * consumer opted in: default (`'legacy'`) instances return `undefined`, + * which keeps their dispatch byte-identical to today's. Transport-edge + * classification (the per-request HTTP entry) always wins and never + * reaches this hook. + */ + protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { + if (this._eraSupport === 'legacy') { + return undefined; + } + // `initialize` is the legacy handshake by definition — unless the + // message carries a valid envelope claim naming a modern revision, in + // which case the claim wins: the message is classified like any other + // enveloped message and served on the modern era, where the era + // registry answers `initialize` with the same plain method-not-found + // it answers every other method that era does not define. A malformed + // or absent claim, or a claim naming a pre-2026 revision, keeps the + // legacy-handshake classification from the per-message predicate. + if (message.method === 'initialize' && carriesValidModernEnvelopeClaim(message.params)) { + const claimedVersion = envelopeClaimVersion(message.params); + if (claimedVersion !== undefined) { + return { era: 'modern', revision: claimedVersion }; + } + } + return classifyInboundMessage(message); + } + /** * Registers the built-in `logging/setLevel` request handler. * @@ -211,19 +375,76 @@ export class Server extends Protocol { }); } + /** + * Era gate for context-related server→client requests, keyed off the era + * of the request currently being served (its classification). + * + * A long-lived dual-era instance is never bound to a single era, so the + * instance-level outbound era gate alone would let a handler that is + * serving a 2026-era request push a server→client wire request + * (sampling, elicitation, roots) onto the connection. The 2026-07-28 + * revision has no server→client JSON-RPC request channel, so the client + * drops the request and the call hangs until timeout. The request + * context therefore applies the same typed local error a strict + * `'modern'` instance raises, per request: spec methods absent from the + * served era's registry fail fast before anything reaches the transport. + * + * Scope: the context request path only (`ctx.mcpReq.send`, + * `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`). Related + * notifications, requests served on the legacy era, and instance-level + * senders used outside a request context are unaffected. + */ + private _assertContextRequestInServedEra(classification: MessageClassification | undefined, method: string): void { + if (classification === undefined) { + return; + } + const servedCodec = codecForVersion( + classification.revision ?? (classification.era === 'modern' ? FIRST_MODERN_PROTOCOL_VERSION : undefined) + ); + // Mirrors the outbound era gate: only spec methods missing from the + // served era are gated; methods the served era defines (and + // consumer-owned extension methods) resolve exactly as before. + if (servedCodec.hasRequestMethod(method) || !codecForVersion(undefined).hasRequestMethod(method)) { + return; + } + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Server-to-client requests are not available on protocol revision ${servedCodec.era}: ` + + `'${method}' cannot be sent while serving a request on that revision. ` + + `Servers obtain client input through request results once multi-round-trip support is available.`, + { method, era: servedCodec.era } + ); + } + protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext { // Only create http when there's actual HTTP transport info or auth info const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; + const classification = transportInfo?.classification; + // Context-related server→client requests are gated by the era of the + // request being served (see _assertContextRequestInServedEra); + // related notifications (`notify`, `log`) are unaffected. + const baseSend = ctx.mcpReq.send as (request: { method: string }, ...rest: unknown[]) => Promise; + const send = ((request: { method: string }, ...rest: unknown[]) => { + this._assertContextRequestInServedEra(classification, request.method); + return baseSend(request, ...rest); + }) as BaseContext['mcpReq']['send']; return { ...ctx, mcpReq: { ...ctx.mcpReq, + send, // Deprecated as of protocol version 2026-07-28 (SEP-2577): `log` and // `requestSampling` remain functional during the deprecation window // (at least twelve months). See ServerContext for migration guidance. log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), - elicitInput: (params, options) => this.elicitInput(params, options), - requestSampling: (params, options) => this.createMessage(params, options) + elicitInput: async (params, options) => { + this._assertContextRequestInServedEra(classification, 'elicitation/create'); + return this.elicitInput(params, options); + }, + requestSampling: async (params, options) => { + this._assertContextRequestInServedEra(classification, 'sampling/createMessage'); + return this.createMessage(params, options); + } }, http: hasHttpInfo ? { @@ -469,7 +690,15 @@ export class Server extends Protocol { // The negotiated version is the instance's connection state — it IS // the wire-era selection for everything this instance sends and // receives from here on (legacy handshake ⇒ a legacy-era version). - this._negotiatedProtocolVersion = protocolVersion; + // The one exception is a dual-era instance: it serves both eras on + // the same long-lived connection, selecting the era per message, so + // the handshake never binds the instance — the result is recorded + // only for the initialize-scoped accessor. + if (this._eraSupport === 'dual-era') { + this._dualEraInitializeVersion = protocolVersion; + } else { + this._negotiatedProtocolVersion = protocolVersion; + } this.transport?.setProtocolVersion?.(protocolVersion); return { @@ -530,10 +759,13 @@ export class Server extends Protocol { * 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` names the revision the * request was sent for, while on 2025-era connections this accessor keeps returning the * `initialize`-negotiated version. The accessor remains functional — instances serving the - * 2026-07-28 era report that revision. + * 2026-07-28 era report that revision. On a long-lived dual-era instance (`eraSupport: + * 'dual-era'`), where the era is selected per message, the accessor keeps its + * initialize-scoped semantics and reports what a legacy `initialize` handshake negotiated + * (or `undefined` when none ran). */ getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; + return this._negotiatedProtocolVersion ?? this._dualEraInitializeVersion; } /** diff --git a/packages/server/test/server/discover.test.ts b/packages/server/test/server/discover.test.ts index c2b96da595..d9806bd1ef 100644 --- a/packages/server/test/server/discover.test.ts +++ b/packages/server/test/server/discover.test.ts @@ -1,9 +1,11 @@ /** * `server/discover` machinery + era-aware supported-version list semantics: * - * - the handler is installed ONLY when the server's supported-versions list - * carries a modern (2026-07-28+) revision; default servers keep answering - * -32601 byte-identically to the deployed fleet + * - the handler is installed ONLY on servers that declare modern-era support + * (`eraSupport: 'dual-era' | 'modern'`); default servers keep answering + * -32601 byte-identically to the deployed fleet, and a modern (2026-07-28+) + * revision in the supported-versions list without that declaration is a + * construction-time TypeError * - the advertisement is modern-only (DV-30) and excludes the * listChanged/subscribe-class capabilities (A11 rider — until the * subscriptions/listen milestone lands) @@ -12,12 +14,10 @@ * site, even when the supported list carries one — the guard that must hold * BEFORE any LATEST/SUPPORTED constant bump. * - * Era is instance state: an inbound `server/discover` is served only by a - * modern-era instance (the method is physically absent from the legacy - * registry). Production marking of modern instances is owned by the - * server-entry milestone; these tests mark instances through the - * package-internal hook the entry will use, and the modern-era request shape - * carries the required per-request `_meta` envelope. + * The HTTP per-request entry still binds its instances to the modern era + * through the package-internal hook; the `markModern` arm of the harness + * stands in for that path, and the modern-era request shape carries the + * required per-request `_meta` envelope. */ import type { DiscoverResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; import { @@ -92,7 +92,7 @@ describe('server/discover handler gating', () => { it('a server with a modern revision in its supported list serves discover on a modern-era instance', async () => { const server = new Server( { name: 'modern-server', version: '2.0.0' }, - { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, instructions: 'hello' } + { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era', instructions: 'hello' } ); const response = await sendRaw(server, discoverRequest, { markModern: true }); expect(isJSONRPCResultResponse(response)).toBe(true); @@ -118,7 +118,10 @@ describe('server/discover handler gating', () => { describe('discover advertisement constraints', () => { it('advertises modern-only versions (DV-30): no 2025-era string ever appears in supportedVersions', async () => { - const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const server = new Server( + { name: 'test', version: '1.0.0' }, + { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } + ); const response = await sendRaw(server, discoverRequest, { markModern: true }); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = DiscoverResultSchema.parse(response.result); @@ -140,7 +143,8 @@ describe('discover advertisement constraints', () => { logging: {}, completions: {} }, - supportedProtocolVersions: DUAL_ERA_VERSIONS + supportedProtocolVersions: DUAL_ERA_VERSIONS, + eraSupport: 'dual-era' } ); const response = await sendRaw(server, discoverRequest, { markModern: true }); @@ -166,7 +170,10 @@ describe('discover advertisement constraints', () => { expect(capabilities).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }); // The legacy initialize advertisement still carries the full capability set. - const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const server = new Server( + { name: 'test', version: '1.0.0' }, + { capabilities, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } + ); const response = await sendRaw(server, initializeRequest(LATEST_PROTOCOL_VERSION)); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = InitializeResultSchema.parse(response.result); @@ -178,7 +185,10 @@ describe('discover advertisement constraints', () => { describe('era-aware counter-offer ordering (the guard that precedes any constant bump)', () => { it('an unknown requested version is countered with the latest LEGACY version even when the list carries a modern one', async () => { - const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const server = new Server( + { name: 'test', version: '1.0.0' }, + { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } + ); const response = await sendRaw(server, initializeRequest('1999-01-01')); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = InitializeResultSchema.parse(response.result); @@ -191,7 +201,10 @@ describe('era-aware counter-offer ordering (the guard that precedes any constant }); it('an initialize REQUESTING the modern revision is also answered with the latest legacy version (initialize never negotiates a modern era)', async () => { - const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); + const server = new Server( + { name: 'test', version: '1.0.0' }, + { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } + ); const response = await sendRaw(server, initializeRequest(MODERN)); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = InitializeResultSchema.parse(response.result); diff --git a/packages/server/test/server/dualEraServing.test.ts b/packages/server/test/server/dualEraServing.test.ts new file mode 100644 index 0000000000..32d9b6dd53 --- /dev/null +++ b/packages/server/test/server/dualEraServing.test.ts @@ -0,0 +1,384 @@ +/** + * Long-lived dual-era serving (`eraSupport: 'dual-era'`) on one connection: + * + * - the legacy vertical (initialize → tools/list → tools/call) is served + * exactly as a 2025 server serves it (no 2026 wire fields anywhere); + * - the modern vertical (server/discover → tools/list → tools/call, every + * request carrying the per-request `_meta` envelope) is served on the + * 2026 era on the SAME connection; + * - the long-lived era gate: a message classified into the legacy era asking + * for `server/discover`, `subscriptions/listen`, or any 2026-only method is + * answered with a plain −32601 carrying ZERO 2026 vocabulary in message or + * data (the dedicated leak test — the gate is not structural on a long-lived + * instance, which hosts both registries); the modern-direction denial of + * legacy-only methods mirrors it. + * - Q10-L2: a hand-constructed server with the default `eraSupport` serves a + * scripted 2025 session with today's exact result shapes and zero 2026 + * vocabulary on the wire. + */ +import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp.js'; + +const MODERN = '2026-07-28'; + +/** + * 2026-era vocabulary that must never leak into a legacy-direction response. + * The gate answers with the same plain `-32601` a 2025 server answers for an + * unknown method — nothing in message or data may reveal that the instance + * also hosts the modern era. + */ +const FORBIDDEN_2026_VOCABULARY = [ + '2026', + 'discover', + 'envelope', + 'modern', + 'dual', + 'era', + '_meta', + 'io.modelcontextprotocol', + 'resultType', + 'protocolVersion', + 'protocol version', + 'subscription' +]; + +/** The 2026-only request methods the era gate must hide from legacy-era traffic. */ +const MODERN_ONLY_METHODS = ['server/discover', 'subscriptions/listen']; + +/** + * Legacy-only methods whose modern-direction denial mirrors the gate. + * (`initialize` is not in this list only because it has its own dedicated + * coverage below: an `initialize` carrying a valid modern envelope claim is + * classified by the claim — the claim wins over the legacy-handshake rule — + * and is denied with the same plain −32601.) + */ +const LEGACY_ONLY_METHODS = ['ping', 'logging/setLevel', 'resources/subscribe']; + +const envelope = (overrides?: Record) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {}, + ...overrides +}); + +function buildServer(options?: { eraSupport?: 'legacy' | 'dual-era' | 'modern' }) { + const server = new McpServer( + { name: 'dual-era-test-server', version: '1.0.0' }, + { + capabilities: { tools: {} }, + instructions: 'test instructions', + ...(options?.eraSupport ? { eraSupport: options.eraSupport } : {}) + } + ); + server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return server; +} + +async function wire(server: McpServer) { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + const waiters = new Map void>(); + peerTx.onmessage = message => { + inbound.push(message); + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + await server.connect(serverTx); + await peerTx.start(); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); + return { request, notify, inbound, close: () => server.close() }; +} + +const initializeRequest = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +describe('dual-era serving on one long-lived connection', () => { + it('serves the legacy vertical and the modern vertical on the same connection, each on its own era', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, notify, close } = await wire(server); + + // --- Legacy vertical: initialize → initialized → tools/list → tools/call. + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(JSON.stringify(init)).not.toContain('resultType'); + expect(JSON.stringify(init)).not.toContain('2026'); + } + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const legacyList = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(legacyList)).toBe(true); + if (isJSONRPCResultResponse(legacyList)) { + expect((legacyList.result as { tools: Array<{ name: string }> }).tools.map(tool => tool.name)).toEqual(['echo']); + expect(JSON.stringify(legacyList)).not.toContain('resultType'); + } + + const legacyCall = await request({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'legacy leg' } } + }); + expect(isJSONRPCResultResponse(legacyCall)).toBe(true); + if (isJSONRPCResultResponse(legacyCall)) { + expect((legacyCall.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'legacy leg' }]); + expect(JSON.stringify(legacyCall)).not.toContain('resultType'); + } + + // --- Modern vertical on the SAME connection: discover → list → call, + // every request carrying the per-request envelope. + const discover = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + if (isJSONRPCResultResponse(discover)) { + const result = discover.result as { supportedVersions?: string[]; resultType?: string }; + expect(result.supportedVersions).toEqual([MODERN]); + expect(result.resultType).toBe('complete'); + } + + const modernList = await request({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(modernList)).toBe(true); + if (isJSONRPCResultResponse(modernList)) { + const result = modernList.result as { tools: Array<{ name: string }>; resultType?: string }; + expect(result.tools.map(tool => tool.name)).toEqual(['echo']); + expect(result.resultType).toBe('complete'); + } + + const modernCall = await request({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(modernCall)).toBe(true); + if (isJSONRPCResultResponse(modernCall)) { + const result = modernCall.result as { content: unknown[]; resultType?: string }; + expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); + expect(result.resultType).toBe('complete'); + } + + // The legacy leg is unaffected by the modern exchanges that ran in between. + const legacyAgain = await request({ jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(legacyAgain)).toBe(true); + expect(JSON.stringify(legacyAgain)).not.toContain('resultType'); + + await close(); + }); + + it('the modern era is reachable without any prior legacy handshake (envelope-first connection)', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + const discover = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + await close(); + }); +}); + +describe('long-lived era gate + zero-2026-vocabulary leak test', () => { + it('a legacy-classified request for any 2026-only method answers a plain −32601 with zero 2026 vocabulary in message or data', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + // Establish the legacy leg first — the gate must hold on a connection + // that is actively serving 2025 traffic. + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + + let id = 10; + for (const method of MODERN_ONLY_METHODS) { + // No envelope claim ⇒ classified legacy ⇒ the modern registry must be invisible. + const response = await request({ jsonrpc: '2.0', id: (id += 1), method, params: {} }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + const error = (response as JSONRPCErrorResponse).error; + expect(error.code).toBe(-32_601); + expect(error.message).toBe('Method not found'); + expect(error.data).toBeUndefined(); + + const serialized = JSON.stringify({ error, id: null }); + for (const term of FORBIDDEN_2026_VOCABULARY) { + expect(serialized.toLowerCase()).not.toContain(term.toLowerCase()); + } + } + await close(); + }); + + it('the modern-direction denial mirrors it: a modern-classified request for a legacy-only method answers −32601', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + let id = 20; + for (const method of LEGACY_ONLY_METHODS) { + const response = await request({ jsonrpc: '2.0', id: (id += 1), method, params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + const error = (response as JSONRPCErrorResponse).error; + expect(error.code).toBe(-32_601); + expect(error.message).toBe('Method not found'); + } + await close(); + }); +}); + +describe('enveloped initialize on a dual-era instance (a valid modern claim wins over the legacy-handshake rule)', () => { + it('an initialize carrying a valid modern envelope claim answers a plain −32601 and is never served by the legacy handshake', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + const response = await request({ jsonrpc: '2.0', id: 30, method: 'initialize', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + const error = (response as JSONRPCErrorResponse).error; + expect(error.code).toBe(-32_601); + expect(error.message).toBe('Method not found'); + expect(error.data).toBeUndefined(); + + // Nothing beyond the normal method-not-found shape leaks 2026 vocabulary. + const serialized = JSON.stringify({ error, id: null }); + for (const term of FORBIDDEN_2026_VOCABULARY) { + expect(serialized.toLowerCase()).not.toContain(term.toLowerCase()); + } + + // The legacy initialize path never ran: the initialize-scoped accessors stay unset. + expect(server.server.getNegotiatedProtocolVersion()).toBeUndefined(); + expect(server.server.getClientVersion()).toBeUndefined(); + + // An envelope-less initialize on the same connection keeps today's behavior: + // the legacy handshake is served exactly as before, with zero 2026 vocabulary. + const init = await request(initializeRequest(31)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(JSON.stringify(init)).not.toContain('resultType'); + expect(JSON.stringify(init)).not.toContain('2026'); + } + await close(); + }); + + it('an initialize with a malformed envelope claim keeps the legacy handshake', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + // The claim key is present but the envelope is incomplete — never a + // silent flip to the modern era; the legacy handshake serves it as before. + const response = await request({ + jsonrpc: '2.0', + id: 40, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'legacy-client', version: '1.0.0' }, + _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } + } + }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + expect((response.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + await close(); + }); + + it('an initialize whose valid envelope claim names a pre-2026 revision keeps the legacy handshake', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + const response = await request({ + jsonrpc: '2.0', + id: 50, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'legacy-client', version: '1.0.0' }, + _meta: envelope({ [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }) + } + }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + expect((response.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + await close(); + }); +}); + +describe('Q10-L2: a hand-constructed server with the default eraSupport on 2025 traffic', () => { + it('serves a scripted 2025 session with the exact 2025 shapes and zero 2026 vocabulary on the wire', async () => { + const server = buildServer(); + const { request, notify, inbound, close } = await wire(server); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect(init.result).toEqual({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'dual-era-test-server', version: '1.0.0' }, + instructions: 'test instructions' + }); + } + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + const tools = (list.result as { tools: Array> }).tools; + expect(tools).toHaveLength(1); + expect(tools[0]).toMatchObject({ name: 'echo', description: 'Echoes the input text' }); + expect(Object.keys(list.result as Record).sort()).toEqual(['tools']); + } + + const call = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'echo', arguments: { text: 'hi' } } }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect(call.result).toEqual({ content: [{ type: 'text', text: 'hi' }] }); + } + + const ping = await request({ jsonrpc: '2.0', id: 4, method: 'ping' }); + expect(isJSONRPCResultResponse(ping)).toBe(true); + if (isJSONRPCResultResponse(ping)) { + expect(ping.result).toEqual({}); + } + + // A default instance keeps answering server/discover with -32601, byte-identical to the deployed fleet. + const discover = await request({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: {} }); + expect(isJSONRPCErrorResponse(discover)).toBe(true); + if (isJSONRPCErrorResponse(discover)) { + expect(discover.error).toEqual({ code: -32_601, message: 'Method not found' }); + } + + // Nothing the server wrote on this 2025 session carries 2026 wire vocabulary. + const wireBytes = JSON.stringify(inbound); + expect(wireBytes).not.toContain('resultType'); + expect(wireBytes).not.toContain('2026'); + expect(wireBytes).not.toContain('io.modelcontextprotocol/'); + + await close(); + }); +}); diff --git a/packages/server/test/server/eraSupport.test.ts b/packages/server/test/server/eraSupport.test.ts new file mode 100644 index 0000000000..0b95b9e46f --- /dev/null +++ b/packages/server/test/server/eraSupport.test.ts @@ -0,0 +1,389 @@ +/** + * `ServerOptions.eraSupport` — the stdio/long-lived-connection era opt-in: + * + * - default `'legacy'` for hand-constructed `Server`/`McpServer`: nothing + * 2026-era is registered or advertised, and a modern revision in + * `supportedProtocolVersions` without the declaration is a construction-time + * `TypeError` (never a silent behavior change). + * - `'dual-era'`: `server/discover` registered without any instance binding, + * modern revisions advertised, both eras served per message. + * - `'modern'`: strict 2026-only — envelope-less requests (including + * `initialize`) answer the unsupported-protocol-version error with the + * supported list; legacy-classified notifications are dropped. + * - TS-01 directionality: a modern-bound instance cannot emit server→client + * wire requests (typed local error); a dual-era instance serving the legacy + * leg still can, while a handler serving a 2026-classified request gets the + * same typed error from the ctx-related request path. + */ +import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { McpServer } from '../../src/server/mcp.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN = '2026-07-28'; +const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS]; + +const envelope = (overrides?: Record) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'era-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {}, + ...overrides +}); + +const initializeRequest = (id: number, requestedVersion = LATEST_PROTOCOL_VERSION): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: requestedVersion, + capabilities: { sampling: {} }, + clientInfo: { name: 'legacy-client', version: '1.0.0' } + } +}); + +interface Connectable { + connect(transport: InstanceType): Promise; + close(): Promise; +} + +/** Wires a server to one long-lived in-memory connection and returns request/notify drivers. */ +async function wireServer(server: Connectable) { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + const waiters = new Map void>(); + peerTx.onmessage = message => { + inbound.push(message); + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + await server.connect(serverTx); + await peerTx.start(); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + return { request, notify, flush, inbound, peerTx, close: () => server.close() }; +} + +describe('construction-time guard (default eraSupport is legacy)', () => { + it('throws a TypeError when supportedProtocolVersions carries a modern revision on a default instance', () => { + expect(() => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS })).toThrow( + TypeError + ); + expect(() => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS })).toThrow( + /eraSupport/ + ); + }); + + it('throws for McpServer too (options are forwarded)', () => { + expect(() => new McpServer({ name: 't', version: '1' }, { supportedProtocolVersions: [MODERN] })).toThrow(TypeError); + }); + + it('does not throw when the modern revision is accompanied by a dual-era or modern declaration', () => { + expect( + () => + new Server( + { name: 't', version: '1' }, + { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } + ) + ).not.toThrow(); + expect( + () => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: [MODERN], eraSupport: 'modern' }) + ).not.toThrow(); + }); + + it('a default legacy-only construction stays exactly as before (no throw, no discover handler)', async () => { + const server = new Server({ name: 't', version: '1' }, { capabilities: {} }); + const { request, close } = await wireServer(server); + const response = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + } + await close(); + }); +}); + +describe("DV-30: server/discover is registered only when eraSupport !== 'legacy'", () => { + it('a dual-era server serves discover with no instance binding and advertises only modern revisions', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + const { request, close } = await wireServer(server); + + const response = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + const result = response.result as { supportedVersions?: string[]; resultType?: string }; + expect(result.supportedVersions).toEqual([MODERN]); + // Served on the modern era: the wire result carries the 2026 result discriminator. + expect(result.resultType).toBe('complete'); + } + await close(); + }); + + it('the served modern revisions are added to the supported list without mutating the shared default constant', () => { + const before = [...SUPPORTED_PROTOCOL_VERSIONS]; + const server = new Server({ name: 'dual', version: '1' }, { capabilities: {}, eraSupport: 'dual-era' }); + expect(SUPPORTED_PROTOCOL_VERSIONS).toEqual(before); + expect(server).toBeDefined(); + }); +}); + +describe("DV-31: strict 'modern' on a long-lived connection", () => { + async function wireModernServer() { + const server = new Server({ name: 'strict', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'modern' }); + server.setRequestHandler('tools/list', () => ({ tools: [] })); + return { server, ...(await wireServer(server)) }; + } + + it('an envelope-less non-initialize request answers −32004 with the supported list', async () => { + const { request, close } = await wireModernServer(); + const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + // Note: this cell shares its numeric code (−32004) with the + // still-disputed header/body mismatch family; the cell itself is + // settled (unsupported protocol version + supported list). + expect(response.error.code).toBe(-32_004); + const data = response.error.data as { supported?: string[]; requested?: string }; + // The strict instance serves only modern revisions, so the supported + // list it advertises names only those (never the legacy defaults). + expect(data.supported).toEqual([MODERN]); + expect(typeof data.requested).toBe('string'); + } + await close(); + }); + + it('an envelope-less initialize answers −32004 with the supported list (never a legacy handshake)', async () => { + const { request, close } = await wireModernServer(); + const response = await request(initializeRequest(2)); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_004); + expect((response.error.data as { supported?: string[] }).supported).toEqual([MODERN]); + expect((response.error.data as { requested?: string }).requested).toBe(LATEST_PROTOCOL_VERSION); + } + await close(); + }); + + it('an initialize carrying a valid modern envelope claim answers a plain −32601 (the claim wins over the legacy-handshake rule)', async () => { + const { request, close } = await wireModernServer(); + const response = await request({ jsonrpc: '2.0', id: 5, method: 'initialize', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + // Classified by its valid modern claim, the request is served on the + // modern era, where `initialize` is answered like every other method + // that era does not define — never with the version error reserved + // for envelope-less requests. + expect(response.error.code).toBe(-32_601); + expect(response.error.message).toBe('Method not found'); + expect(response.error.data).toBeUndefined(); + } + await close(); + }); + + it('a legacy-classified notification is dropped without a response', async () => { + const { notify, flush, inbound, close } = await wireModernServer(); + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await flush(); + expect(inbound).toHaveLength(0); + await close(); + }); + + it('an enveloped modern request is served', async () => { + const { request, close } = await wireModernServer(); + const response = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + expect((response.result as { tools?: unknown[] }).tools).toEqual([]); + expect((response.result as { resultType?: string }).resultType).toBe('complete'); + } + await close(); + }); + + it('server/discover advertises only modern revisions', async () => { + const { request, close } = await wireModernServer(); + const response = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + expect((response.result as { supportedVersions?: string[] }).supportedVersions).toEqual([MODERN]); + } + await close(); + }); + + it('a mixed legacy+modern supported list is reduced to its modern subset at construction', async () => { + const server = new Server( + { name: 'strict', version: '1' }, + { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'modern' } + ); + const { request, close } = await wireServer(server); + + // The unsupported-protocol-version handoff names only the modern + // revisions: the legacy entries the consumer passed are never served + // by a strict instance, so they are not advertised either. + const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect((response.error.data as { supported?: string[] }).supported).toEqual([MODERN]); + } + await close(); + }); +}); + +describe('TS-01 directionality (era-keyed direction enforcement)', () => { + it('a strict-modern instance cannot emit server→client wire requests: typed local error, nothing reaches the transport', async () => { + const server = new Server({ name: 'strict', version: '1' }, { capabilities: {}, eraSupport: 'modern' }); + const { inbound, flush, close } = await wireServer(server); + + await expect( + server.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }) + ).rejects.toThrow(/not supported by the negotiated protocol version/); + await flush(); + expect(inbound).toHaveLength(0); + await close(); + }); + + it('a dual-era instance serving the legacy leg still emits server→client requests (permitted per the message era)', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: {}, eraSupport: 'dual-era' }); + const { request, inbound, flush, close } = await wireServer(server); + + // Legacy leg: the 2025 client initializes and declares sampling support. + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + + // The server-initiated sampling request is legal on the legacy leg and reaches the wire. + const pending = server.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }); + pending.catch(() => { + // The peer never answers; the request is torn down with the connection below. + }); + await flush(); + expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(true); + await close(); + }); + + it('a handler serving a modern-classified request gets the typed error from the ctx sampling helper; nothing reaches the transport', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + let captured: unknown; + server.setRequestHandler('tools/list', async (_request, ctx) => { + try { + await ctx.mcpReq.requestSampling({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }); + } catch (error) { + captured = error; + } + return { tools: [] }; + }); + const { request, inbound, flush, close } = await wireServer(server); + + const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(response)).toBe(true); + await flush(); + + expect(captured).toBeInstanceOf(SdkError); + expect((captured as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((captured as SdkError).message).toMatch(/not available on protocol revision 2026-07-28/); + expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(false); + await close(); + }); + + it('a raw ctx server→client request send while serving a modern-classified request is rejected the same way', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + let captured: unknown; + server.setRequestHandler('tools/list', async (_request, ctx) => { + try { + await ctx.mcpReq.send({ method: 'roots/list' }); + } catch (error) { + captured = error; + } + return { tools: [] }; + }); + const { request, inbound, flush, close } = await wireServer(server); + + const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(response)).toBe(true); + await flush(); + + expect(captured).toBeInstanceOf(SdkError); + expect((captured as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect(inbound.some(message => (message as JSONRPCRequest).method === 'roots/list')).toBe(false); + await close(); + }); + + it('the same ctx sampling helper on a legacy-classified request still reaches the wire (permitted per the message era)', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + server.setRequestHandler('tools/list', (_request, ctx) => { + const pending = ctx.mcpReq.requestSampling({ + messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], + maxTokens: 1 + }); + pending.catch(() => { + // The peer never answers; the request is torn down with the connection below. + }); + return { tools: [] }; + }); + const { request, inbound, flush, close } = await wireServer(server); + + // Legacy leg: the 2025 client initializes and declares sampling support. + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + + const response = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(response)).toBe(true); + await flush(); + + expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(true); + await close(); + }); +}); + +describe('accessor split on long-lived dual-era instances', () => { + it('getClientCapabilities/getClientVersion/getNegotiatedProtocolVersion keep initialize-scoped semantics; modern envelopes never backfill them', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + server.setRequestHandler('tools/list', () => ({ tools: [] })); + const { request, close } = await wireServer(server); + + // Legacy handshake populates the initialize-scoped accessors. + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + expect(server.getClientVersion()).toEqual({ name: 'legacy-client', version: '1.0.0' }); + expect(server.getClientCapabilities()).toEqual({ sampling: {} }); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + // A modern message carrying a different client identity in its envelope + // is served, but never backfills the instance-level accessors (per-message + // identity is read from the per-request context, not instance state). + const modern = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: { + _meta: envelope({ [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '9.9.9' } }) + } + }); + expect(isJSONRPCResultResponse(modern)).toBe(true); + expect(server.getClientVersion()).toEqual({ name: 'legacy-client', version: '1.0.0' }); + expect(server.getClientCapabilities()).toEqual({ sampling: {} }); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await close(); + }); +}); diff --git a/test/integration/test/__fixtures__/dualEraStdioServer.ts b/test/integration/test/__fixtures__/dualEraStdioServer.ts new file mode 100644 index 0000000000..46499f6156 --- /dev/null +++ b/test/integration/test/__fixtures__/dualEraStdioServer.ts @@ -0,0 +1,29 @@ +/** + * A dual-era stdio server fixture: `eraSupport: 'dual-era'` on an otherwise + * ordinary hand-constructed McpServer connected to the unchanged + * StdioServerTransport. Spawned as a real child process by + * `test/server/dualEraStdio.test.ts`. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const server = new McpServer( + { name: 'dual-era-stdio-fixture', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'dual-era stdio fixture', eraSupport: 'dual-era' } +); + +server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] +})); + +await server.connect(new StdioServerTransport()); + +const exit = async () => { + await server.close(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}; + +process.on('SIGINT', exit); +process.on('SIGTERM', exit); diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 8de980f16a..5b833de3eb 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -6,7 +6,6 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, - setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { McpServer, Server } from '@modelcontextprotocol/server'; @@ -188,12 +187,15 @@ test('should run a fresh initialize handshake after close() when the previous co const supportedProtocolVersions = [MODERN_REVISION, ...SUPPORTED_PROTOCOL_VERSIONS]; const connectModern = async (client: Client) => { - const server = new Server({ name: 'modern server', version: '1.0' }, { capabilities: {}, supportedProtocolVersions }); + // Serving a 2026-era revision on a hand-constructed instance is a declared act + // (eraSupport); a dual-era instance answers the client's server/discover probe + // per message with no instance binding. + const server = new Server( + { name: 'modern server', version: '1.0' }, + { capabilities: {}, supportedProtocolVersions, eraSupport: 'dual-era' } + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); - // Stand-in for the modern-era server entry (instance binding): mark the server instance - // as serving the modern era so it can answer the client's server/discover probe. - setNegotiatedProtocolVersion(server, MODERN_REVISION); await client.connect(clientTransport); }; diff --git a/test/integration/test/client/discoverRoundtrip.test.ts b/test/integration/test/client/discoverRoundtrip.test.ts index cdf551d60e..14b88a7cf2 100644 --- a/test/integration/test/client/discoverRoundtrip.test.ts +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -4,17 +4,17 @@ * era-aware counter-offer end to end (a legacy client against a server whose * supported list carries a 2026 revision never sees a 2026 version string). * - * Era is instance state on the server: an inbound `server/discover` is served - * only by a modern-era instance (the method is physically absent from the - * legacy registry). Production binding of modern-era instances belongs to the - * server-side entry that classifies inbound traffic; until it lands these - * tests bind the instance through the package-internal hook it will use. + * Serving a 2026-era revision on a hand-constructed instance is a declared + * act: the servers under test pass `eraSupport: 'dual-era'` (a modern + * revision in the supported list without that declaration is a + * construction-time TypeError), and a dual-era instance answers the + * `server/discover` probe per message with no instance binding. */ import type { Server as HttpServer } from 'node:http'; import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { SdkError, SdkErrorCode, setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import { SdkError, SdkErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; @@ -39,14 +39,14 @@ describe('server/discover round-trip against a modern server', () => { while (cleanups.length > 0) await cleanups.pop()!(); }); - async function startServer(options: { modernEraInstance: boolean }) { + async function startServer(options: { kind: 'dual-era' | 'legacy-only' }) { const httpServer: HttpServer = createServer(); const mcpServer = new McpServer( { name: 'dual-era-server', version: '2.0.0' }, { capabilities: { tools: { listChanged: true } }, - supportedProtocolVersions: DUAL_ERA_VERSIONS, - instructions: 'dual era' + instructions: 'dual era', + ...(options.kind === 'dual-era' ? { supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' as const } : {}) } ); mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ @@ -54,11 +54,6 @@ describe('server/discover round-trip against a modern server', () => { })); const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await mcpServer.connect(serverTransport); - if (options.modernEraInstance) { - // Stand-in for the server-side entry (instance binding): mark the - // instance as serving the modern era so it can answer the probe. - setNegotiatedProtocolVersion(mcpServer.server, MODERN); - } httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); const baseUrl = await listenOnRandomPort(httpServer); cleanups.push(async () => { @@ -70,7 +65,7 @@ describe('server/discover round-trip against a modern server', () => { } it('pin-mode 2026 client: server/discover → version selection, no initialize ever sent', async () => { - const baseUrl = await startServer({ modernEraInstance: true }); + const baseUrl = await startServer({ kind: 'dual-era' }); const { bodies, fetchFn } = recordingFetch(); const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); @@ -88,7 +83,7 @@ describe('server/discover round-trip against a modern server', () => { }); it('auto-mode client selects the modern era on the same server', async () => { - const baseUrl = await startServer({ modernEraInstance: true }); + const baseUrl = await startServer({ kind: 'dual-era' }); const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(baseUrl)); cleanups.push(() => client.close()); @@ -96,12 +91,11 @@ describe('server/discover round-trip against a modern server', () => { expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); }); - it('auto-mode against the same server NOT bound to the modern era falls back to the legacy handshake', async () => { - // A server instance serves the legacy era until it is bound to the - // modern one (binding is owned by the server-side entry); the probe is - // answered -32601 and the client falls back cleanly on the same - // connection. - const baseUrl = await startServer({ modernEraInstance: false }); + it('auto-mode against a server that has not opted into modern-era support falls back to the legacy handshake', async () => { + // A hand-constructed server with the default eraSupport never serves + // server/discover: the probe is answered -32601 and the client falls + // back cleanly on the same connection. + const baseUrl = await startServer({ kind: 'legacy-only' }); const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(baseUrl)); cleanups.push(() => client.close()); @@ -111,8 +105,8 @@ describe('server/discover round-trip against a modern server', () => { expect(result.content).toEqual([{ type: 'text', text: 'fallback' }]); }); - it('a plain legacy client against a server with a dual-era list never meets a 2026 version string (counter-offer ordering, e2e)', async () => { - const baseUrl = await startServer({ modernEraInstance: false }); + it('a plain legacy client against a dual-era server never meets a 2026 version string (counter-offer ordering, e2e)', async () => { + const baseUrl = await startServer({ kind: 'dual-era' }); const { fetchFn } = recordingFetch(); const responses: string[] = []; diff --git a/test/integration/test/server/dualEraStdio.test.ts b/test/integration/test/server/dualEraStdio.test.ts new file mode 100644 index 0000000000..bc0b63088f --- /dev/null +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -0,0 +1,194 @@ +/** + * Real-pipe dual-era stdio coverage: the fixture server + * (`__fixtures__/dualEraStdioServer.ts`, `eraSupport: 'dual-era'`, unchanged + * `StdioServerTransport`) is spawned as a real child process and driven over + * its stdio pipe by + * + * - a plain 2025 client (the `initialize` vertical, served exactly as today), + * - the negotiating client in auto mode (the 2026-07-28 vertical: + * `server/discover` on the pipe, then list → call with the per-request + * envelope), and + * - the long-lived era-gate negative on one connection: a legacy-classified + * `server/discover` answers a plain −32601 with zero 2026 vocabulary, while + * the same connection keeps serving both eras. + * + * Stdio behavior has no conformance harness (upstream conformance issue #258); + * this SDK e2e suite is its referee. + */ +import path from 'node:path'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__'); +const MODERN = '2026-07-28'; + +const FORBIDDEN_2026_VOCABULARY = ['2026', 'discover', 'envelope', 'modern', 'era', '_meta', 'io.modelcontextprotocol', 'resultType']; + +function spawnFixtureTransport(): StdioClientTransport { + return new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', 'dualEraStdioServer.ts'], + cwd: FIXTURES_DIR + }); +} + +/** Records every message the server writes onto the pipe (without detaching the client). */ +function recordInbound(transport: StdioClientTransport): JSONRPCMessage[] { + const inbound: JSONRPCMessage[] = []; + const original = transport.onmessage; + transport.onmessage = (message, extra) => { + inbound.push(message); + original?.(message, extra); + }; + return inbound; +} + +/** Records every message the client writes onto the pipe. */ +function recordOutbound(transport: StdioClientTransport): JSONRPCMessage[] { + const outbound: JSONRPCMessage[] = []; + const originalSend = transport.send.bind(transport); + transport.send = async (message, options) => { + outbound.push(message); + return originalSend(message, options); + }; + return outbound; +} + +/** Sends a raw JSON-RPC request on the live pipe and resolves with the matching response. */ +async function rawRequest(transport: StdioClientTransport, inbound: JSONRPCMessage[], request: JSONRPCMessage): Promise { + const id = (request as { id: string | number }).id; + const seen = inbound.length; + await transport.send(request); + return vi.waitFor( + () => { + const match = inbound.slice(seen).find(message => (message as { id?: string | number }).id === id); + if (!match) throw new Error('no response yet'); + return match; + }, + { timeout: 5000 } + ); +} + +describe('dual-era stdio server over a real child-process pipe', () => { + vi.setConfig({ testTimeout: 30_000 }); + + it('legacy vertical: a plain 2025 client is served via initialize, and the era gate stays vocabulary-clean on the same connection', async () => { + const transport = spawnFixtureTransport(); + const client = new Client({ name: 'legacy-pipe-client', version: '1.0.0' }); + // Raw writes below produce responses the protocol layer does not track. + client.onerror = () => {}; + + try { + await client.connect(transport); + const inbound = recordInbound(transport); + + // The 2025 vertical, byte-shape checks included. + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['echo']); + const result = await client.callTool({ name: 'echo', arguments: { text: 'over the real pipe' } }); + expect(result.content).toEqual([{ type: 'text', text: 'over the real pipe' }]); + expect(JSON.stringify(inbound)).not.toContain('resultType'); + + // Era-gate negative on the SAME connection: a legacy-classified + // server/discover answers a plain −32601 with zero 2026 vocabulary. + const gate = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'raw-gate-1', + method: 'server/discover', + params: {} + }); + const error = (gate as { error: { code: number; message: string; data?: unknown } }).error; + expect(error.code).toBe(-32_601); + expect(error.message).toBe('Method not found'); + expect(error.data).toBeUndefined(); + const serialized = JSON.stringify(error).toLowerCase(); + for (const term of FORBIDDEN_2026_VOCABULARY) { + expect(serialized).not.toContain(term.toLowerCase()); + } + } finally { + await client.close(); + } + }); + + it('modern vertical: the auto-negotiating client reaches 2026-07-28 via server/discover on the pipe and both eras serve on one connection', async () => { + const transport = spawnFixtureTransport(); + const outbound = recordOutbound(transport); + const client = new Client({ name: 'modern-pipe-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + client.onerror = () => {}; + + try { + await client.connect(transport); + const inbound = recordInbound(transport); + + // 2026 negotiated via discover on the pipe — no initialize was ever written. + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(outbound.some(message => (message as { method?: string }).method === 'initialize')).toBe(false); + expect((outbound[0] as { method?: string }).method).toBe('server/discover'); + + // Modern vertical: list → call, every request carrying the per-request envelope. + // (Attaching it explicitly is the documented stop-gap until automatic + // per-request envelope emission lands client-side.) + const envelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'modern-pipe-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + // The list leg is asserted at the wire level: the 2026 wire schema + // for cacheable list results requires the ttlMs/cacheScope stamps, + // whose server-side stamping ships with the result-stamping + // milestone — the client-side typed decode of tools/list on the + // modern era completes once that lands. + const modernList = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'raw-modern-list', + method: 'tools/list', + params: { _meta: envelope } + }); + const modernListResult = (modernList as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; + expect(modernListResult?.tools?.map(tool => tool.name)).toEqual(['echo']); + expect(modernListResult?.resultType).toBe('complete'); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope } + }); + expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); + + // Both eras concurrently on ONE connection: a raw legacy (envelope-less) + // request on the same pipe is served on the 2025 era… + const legacyList = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'raw-legacy-list', + method: 'tools/list', + params: {} + }); + const legacyResult = (legacyList as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; + expect(legacyResult?.tools?.map(tool => tool.name)).toEqual(['echo']); + expect(legacyResult?.resultType).toBeUndefined(); + + // …while the era-gate negative holds on the same connection too. + const gate = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'raw-gate-2', + method: 'subscriptions/listen', + params: {} + }); + const error = (gate as { error: { code: number; message: string; data?: unknown } }).error; + expect(error.code).toBe(-32_601); + expect(error.message).toBe('Method not found'); + expect(error.data).toBeUndefined(); + } finally { + await client.close(); + } + }); +}); From e22980bf3b583999e2b0f09e4dd2e09783c4a1e9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:27:47 +0100 Subject: [PATCH 16/37] =?UTF-8?q?feat(server):=20complete=20the=20stateles?= =?UTF-8?q?s=20call=20=E2=80=94=20result=20stamping,=20cache=20hints,=20an?= =?UTF-8?q?d=20the=20error=20status=20matrix=20(#2306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cacheable-result-cache-fields.md | 6 + .changeset/missing-client-capability-error.md | 7 + docs/migration.md | 30 ++ packages/core/src/exports/public/index.ts | 7 +- packages/core/src/index.ts | 2 + .../shared/clientCapabilityRequirements.ts | 99 +++++++ .../core/src/shared/inboundClassification.ts | 29 +- packages/core/src/shared/resultCacheHints.ts | 138 +++++++++ packages/core/src/types/errors.ts | 49 +++- packages/core/src/types/types.ts | 13 + packages/core/src/wire/codec.ts | 8 +- packages/core/src/wire/rev2026-07-28/codec.ts | 23 +- .../src/wire/rev2026-07-28/encodeContract.ts | 127 ++++++++ .../clientCapabilityRequirements.test.ts | 60 ++++ .../test/shared/errorHttpStatusMatrix.test.ts | 98 +++++++ .../shared/inboundLadderCellSheet.test.ts | 6 + .../missingClientCapabilityError.test.ts | 64 +++++ .../core/test/wire/encodeContract.test.ts | 201 +++++++++++++ .../test/wire/stampingSuppression.test.ts | 270 +++++++++++++++++ packages/server/src/index.ts | 4 + .../server/src/server/createMcpHandler.ts | 44 ++- packages/server/src/server/mcp.ts | 47 ++- packages/server/src/server/server.ts | 47 ++- .../server/test/server/cacheHints.test.ts | 272 ++++++++++++++++++ .../createMcpHandlerCapabilityGate.test.ts | 98 +++++++ 25 files changed, 1707 insertions(+), 42 deletions(-) create mode 100644 .changeset/cacheable-result-cache-fields.md create mode 100644 .changeset/missing-client-capability-error.md create mode 100644 packages/core/src/shared/clientCapabilityRequirements.ts create mode 100644 packages/core/src/shared/resultCacheHints.ts create mode 100644 packages/core/src/wire/rev2026-07-28/encodeContract.ts create mode 100644 packages/core/test/shared/clientCapabilityRequirements.test.ts create mode 100644 packages/core/test/shared/errorHttpStatusMatrix.test.ts create mode 100644 packages/core/test/types/missingClientCapabilityError.test.ts create mode 100644 packages/core/test/wire/encodeContract.test.ts create mode 100644 packages/core/test/wire/stampingSuppression.test.ts create mode 100644 packages/server/test/server/cacheHints.test.ts create mode 100644 packages/server/test/server/createMcpHandlerCapabilityGate.test.ts diff --git a/.changeset/cacheable-result-cache-fields.md b/.changeset/cacheable-result-cache-fields.md new file mode 100644 index 0000000000..cb8d917e3f --- /dev/null +++ b/.changeset/cacheable-result-cache-fields.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +Results of the cacheable 2026-07-28 operations (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`) now always carry the revision's required `ttlMs`/`cacheScope` fields when served on that revision, defaulting to `ttlMs: 0` / `cacheScope: 'private'`. Servers can configure the emitted values with the new `ServerOptions.cacheHints` option (per operation) and the new `cacheHint` member of the `registerResource` config (per resource); resolution is per field, most specific author first: cache fields returned by a handler win over the per-resource hint, which wins over the per-operation hint, and configured hints are validated at construction/registration time (`RangeError` on invalid values). Responses on 2025-era connections are unchanged and never carry these fields. Note for untyped callers: `registerResource` now interprets a `cacheHint` key in its config object — it is validated and kept out of the resource's list metadata, where it was previously passed through as ordinary metadata. diff --git a/.changeset/missing-client-capability-error.md b/.changeset/missing-client-capability-error.md new file mode 100644 index 0000000000..9653679e05 --- /dev/null +++ b/.changeset/missing-client-capability-error.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Add `MissingRequiredClientCapabilityError`, the typed error class for the 2026-07-28 `-32003` protocol error (processing a request requires a capability the client did not declare). Its `data.requiredCapabilities` lists the missing capabilities and `ProtocolError.fromError` recognizes the code/data shape. The 2026-07-28 HTTP entry gains a pre-dispatch gate that refuses a request requiring an undeclared client capability with this error and HTTP status `400`; no method served on the 2026-07-28 registry currently carries such a requirement, so observable behavior is unchanged until methods with capability requirements exist. diff --git a/docs/migration.md b/docs/migration.md index ce0602cf71..70ef7df0e8 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1101,6 +1101,36 @@ can still send them to the 2025-era clients it serves via `initialize`. On a `'d Declaring `eraSupport: 'dual-era'` is also an assertion that your handlers are ready to serve modern-era requests (for example, that they read per-request client identity from `ctx.mcpReq.envelope` rather than the connection-scoped accessors — see the next section). A future release may add per-handler era declarations as the basis for a safe automatic default; for now the connection-level `eraSupport` option is the whole opt-in surface. +### Cache fields and cache hints for cacheable 2026-07-28 results + +The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`). When serving that revision, the SDK now always emits both fields, +defaulting to `ttlMs: 0` and `cacheScope: 'private'` — the most conservative policy, equivalent to "do not cache". To advertise a real cache policy: + +```typescript +const server = new McpServer( + { name: 'my-server', version: '1.0.0' }, + { + capabilities: { tools: {}, resources: {} }, + // per-operation hints, used when a result does not carry its own values + cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } } + } +); + +// per-resource hint for that resource's resources/read results +server.registerResource('config', 'config://app', { cacheHint: { ttlMs: 5_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: '…' }] +})); +``` + +Resolution is per field, most specific author first: for each of `ttlMs` and `cacheScope`, a value returned by the handler itself (when valid) wins over the per-resource `cacheHint`, which wins over `ServerOptions.cacheHints[operation]`, which wins over the default — so a +per-resource hint that sets only one field never suppresses the other field configured at the operation level. Configured hints are validated when they are configured — an invalid `ttlMs` (negative or non-integer) or `cacheScope` throws a `RangeError`. Responses on +2025-era connections never carry these fields, with or without configuration. + +### Typed `-32003` missing-client-capability error + +`MissingRequiredClientCapabilityError` is the typed error class for the 2026-07-28 `-32003` protocol error: processing a request requires a capability the client did not declare in the request's `clientCapabilities`. Its `data.requiredCapabilities` lists the missing +capabilities, and `ProtocolError.fromError` recognizes the code/data shape (recognize peers' errors by their code and `error.data`, not by `instanceof`). When the HTTP entry refuses such a request, the response uses HTTP status `400` as the specification requires. + ### Client identity accessors deprecated in favor of per-request context `Server.getClientCapabilities()`, `Server.getClientVersion()` and `Server.getNegotiatedProtocolVersion()` are deprecated (they remain functional). On 2026-07-28 requests the client's identity travels with each request in the validated `_meta` envelope and is available to diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index ec0be8986c..88b806707f 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -94,7 +94,12 @@ export { export { ProtocolErrorCode } from '../../types/enums.js'; // Error classes -export { ProtocolError, UnsupportedProtocolVersionError, UrlElicitationRequiredError } from '../../types/errors.js'; +export { + MissingRequiredClientCapabilityError, + ProtocolError, + UnsupportedProtocolVersionError, + UrlElicitationRequiredError +} from '../../types/errors.js'; // Type guards and message parsing export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e85490f204..b74b370335 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,11 +2,13 @@ export * from './auth/errors.js'; export * from './errors/sdkErrors.js'; export * from './shared/auth.js'; export * from './shared/authUtils.js'; +export * from './shared/clientCapabilityRequirements.js'; export * from './shared/envelope.js'; export * from './shared/inboundClassification.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/protocolEras.js'; +export * from './shared/resultCacheHints.js'; export * from './shared/stdio.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; diff --git a/packages/core/src/shared/clientCapabilityRequirements.ts b/packages/core/src/shared/clientCapabilityRequirements.ts new file mode 100644 index 0000000000..19c5b1a310 --- /dev/null +++ b/packages/core/src/shared/clientCapabilityRequirements.ts @@ -0,0 +1,99 @@ +/** + * Client-capability requirements for inbound requests (protocol revision + * 2026-07-28). + * + * The 2026-07-28 revision carries the client's declared capabilities on every + * request (`io.modelcontextprotocol/clientCapabilities`), and a server MUST + * NOT rely on capabilities the client did not declare: when processing a + * request requires an undeclared capability, the server answers + * `MissingRequiredClientCapabilityError` (`-32003`) with + * `data.requiredCapabilities` listing what is missing — HTTP status `400` on + * HTTP transports. + * + * This module is the shared, pure half of that rule. It is written for three + * call sites: + * + * 1. the pre-dispatch feature gate at the HTTP entry (a request to a method + * whose processing structurally requires a client capability is refused + * before dispatch), + * 2. the outbound input-request leg of multi round-trip requests (a server + * must not embed an input request the client cannot satisfy) — lands with + * the input-request engine, + * 3. the legacy-session pre-check before bridging input requests onto a + * 2025-era session — lands with that bridge. + * + * All three share {@linkcode missingClientCapabilities}; the per-method + * requirement table below feeds call site 1 only. + */ +import type { ClientCapabilities } from '../types/types.js'; + +/** + * Inbound request methods whose processing structurally requires a client + * capability, keyed by method, valued by the capabilities required. + * + * Currently empty: none of the request methods served on the 2026-07-28 + * registry unconditionally requires a client capability. Entries appear here + * when such methods exist — for example requests whose handling embeds + * elicitation or sampling input requests (the input-request engine), or + * opt-in subscription delivery. Handler-conditional requirements (a specific + * tool that needs sampling) are not expressible as a static method table and + * are enforced at the point the requirement arises instead. + */ +export const REQUIRED_CLIENT_CAPABILITIES_BY_METHOD: Readonly> = {}; + +/** + * The client capabilities a request method structurally requires, or + * `undefined` when the method has no static requirement. + */ +export function requiredClientCapabilitiesForRequest(method: string): ClientCapabilities | undefined { + return Object.hasOwn(REQUIRED_CLIENT_CAPABILITIES_BY_METHOD, method) ? REQUIRED_CLIENT_CAPABILITIES_BY_METHOD[method] : undefined; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Computes the subset of `required` client capabilities the client did not + * declare. Returns `undefined` when every required capability is declared; + * otherwise returns an object in the `ClientCapabilities` shape containing + * exactly the missing capabilities (suitable for + * `data.requiredCapabilities` on the `-32003` error). + * + * A capability counts as declared when its top-level key is present on the + * declared capabilities; when the requirement names nested members (for + * example `elicitation: { url: {} }`), each named member must also be present + * under the declared capability. An absent or empty `declared` value means + * nothing is declared — every required capability is missing (the structural + * clean-refusal posture for sessions with no per-request capability view). + */ +export function missingClientCapabilities( + required: ClientCapabilities, + declared: ClientCapabilities | undefined +): ClientCapabilities | undefined { + const missing: Record = {}; + + for (const [capability, requirement] of Object.entries(required)) { + if (requirement === undefined) { + continue; + } + const declaredValue = declared === undefined ? undefined : (declared as Record)[capability]; + if (declaredValue === undefined) { + missing[capability] = requirement; + continue; + } + if (isPlainObject(requirement) && isPlainObject(declaredValue)) { + const missingMembers: Record = {}; + for (const [member, memberRequirement] of Object.entries(requirement)) { + if (memberRequirement !== undefined && declaredValue[member] === undefined) { + missingMembers[member] = memberRequirement; + } + } + if (Object.keys(missingMembers).length > 0) { + missing[capability] = missingMembers; + } + } + } + + return Object.keys(missing).length > 0 ? (missing as ClientCapabilities) : undefined; +} diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 247567516d..14d7b16f54 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -167,8 +167,13 @@ export interface InboundValidationRungDescriptor { rung: InboundValidationRung; /** Evaluation order: lower runs first; an earlier rung's outcome wins over a later rung's. */ order: number; - /** Where the rung is evaluated: at the HTTP entry edge or at protocol dispatch. */ - evaluatedAt: 'edge' | 'dispatch'; + /** + * Where the rung is evaluated: at the HTTP entry edge by + * {@linkcode classifyInboundRequest} (`edge`), by the HTTP entry after + * classification but before dispatch (`pre-dispatch`), or by the protocol + * layer at dispatch (`dispatch`). + */ + evaluatedAt: 'edge' | 'pre-dispatch' | 'dispatch'; /** The JSON-RPC error codes this rung can produce (empty when the rung only routes). */ codes: readonly number[]; /** Conformance scenarios that exercise this rung (where one exists). */ @@ -183,9 +188,11 @@ export interface InboundValidationRungDescriptor { * The edge rungs are evaluated by {@linkcode classifyInboundRequest}; the * dispatch rungs are evaluated by the protocol layer once the classified * message is injected into a per-request server instance (the era registry - * gate, the envelope requiredness check, per-method params validation, and - * the client-capability check). The order is the precedence: a request that - * fails several rungs is answered by the earliest one. + * gate, the envelope requiredness check, and per-method params validation). + * The client-capability rung is evaluated by the HTTP entry itself, + * pre-dispatch, on the validated envelope the classifier produced — see that + * rung's rationale for the ordering caveat. The order is the precedence: a + * request that fails several rungs is answered by the earliest one. */ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor[] = [ { @@ -249,12 +256,16 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor { rung: 'client-capabilities', order: 7, - evaluatedAt: 'dispatch', + evaluatedAt: 'pre-dispatch', codes: [ProtocolErrorCode.MissingRequiredClientCapability], conformance: ['server-stateless'], rationale: - 'Capability assertion runs after envelope validation and method resolution, immediately before the handler; the ' + - 'emission itself ships with the capability-policy work and is recorded here for ordering only.' + 'The capability requirement is checked by the HTTP entry, pre-dispatch, against the validated envelope the ' + + 'classifier produced — pinning the spec-mandated HTTP 400 independently of how dispatch- and handler-produced ' + + 'errors are mapped. The documented order (after method resolution and params validation) is preserved observably ' + + 'only while the requirement table is empty: once a served method gains a requirement entry, a request that is ' + + 'missing the capability and would also fail a dispatch rung is answered by this gate first, so the entry must ' + + 'consult the method registry before the gate if the documented precedence is to stay observable.' } ]; @@ -277,6 +288,8 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor * handler-produced invalid-params error is always in-band. */ export const LADDER_ERROR_HTTP_STATUS: Readonly> = { + [ProtocolErrorCode.ParseError]: 400, + [ProtocolErrorCode.InvalidRequest]: 400, [ProtocolErrorCode.MethodNotFound]: 404, [ProtocolErrorCode.UnsupportedProtocolVersion]: 400, [ProtocolErrorCode.MissingRequiredClientCapability]: 400, diff --git a/packages/core/src/shared/resultCacheHints.ts b/packages/core/src/shared/resultCacheHints.ts new file mode 100644 index 0000000000..a1786f3a35 --- /dev/null +++ b/packages/core/src/shared/resultCacheHints.ts @@ -0,0 +1,138 @@ +/** + * Cache-hint plumbing for cacheable results (protocol revision 2026-07-28). + * + * The 2026-07-28 revision requires `ttlMs`/`cacheScope` on the cacheable + * result types (SEP-2549 `CacheableResult`). The values are resolved at the + * era-aware encode seam (the 2026 wire codec's `encodeResult`), most specific + * author first: + * + * 1. fields the handler returned on the result itself (when valid), + * 2. a configured cache hint attached by the server layer + * (per-registration hint, then the server-level per-operation hint, + * combined per field — see {@linkcode attachCacheHintFallback}), + * 3. the conservative defaults `{ ttlMs: 0, cacheScope: 'private' }`. + * + * The configured hint travels from the (era-blind) server configuration to the + * (era-aware) encode seam on a symbol-keyed property of the result object — + * {@linkcode RESULT_CACHE_HINT_FALLBACK}. Symbol-keyed properties are never + * serialized to JSON, so attaching a hint can never change what a 2025-era + * response looks like on the wire: only the 2026-era codec reads (and removes) + * it while filling the required fields. The 2025-era codec has no cache code + * path at all. + */ + +/** The cache scopes defined for cacheable results (SEP-2549). */ +export type CacheScope = 'public' | 'private'; + +/** + * A cache hint for a cacheable result (protocol revision 2026-07-28): the + * values to emit for `ttlMs` / `cacheScope` when the handler does not provide + * them itself. Absent fields fall back to the conservative defaults + * (`ttlMs: 0`, `cacheScope: 'private'`). + */ +export interface CacheHint { + /** Cache lifetime in milliseconds. Must be a non-negative safe integer. */ + ttlMs?: number; + /** Whether the result may be cached by shared caches (`public`) or only by the requesting client (`private`). */ + cacheScope?: CacheScope; +} + +/** + * The operations whose results are cacheable on the 2026-07-28 revision (the + * `CacheableResult` extenders). This list is closed: no other operation's + * result ever receives cache fields from the SDK. + */ +export const CACHEABLE_RESULT_METHODS = [ + 'tools/list', + 'prompts/list', + 'resources/list', + 'resources/templates/list', + 'resources/read', + 'server/discover' +] as const; + +/** A method whose result is cacheable on the 2026-07-28 revision. */ +export type CacheableResultMethod = (typeof CACHEABLE_RESULT_METHODS)[number]; + +/** Whether the given method's result is cacheable on the 2026-07-28 revision. */ +export function isCacheableResultMethod(method: string): method is CacheableResultMethod { + return (CACHEABLE_RESULT_METHODS as readonly string[]).includes(method); +} + +/** + * The symbol-keyed carrier for a configured cache hint on a result object. + * Symbol properties are invisible to JSON serialization, so the carrier can be + * attached era-blind: only the 2026-era encode seam consumes it. + */ +export const RESULT_CACHE_HINT_FALLBACK: unique symbol = Symbol('modelcontextprotocol.resultCacheHintFallback'); + +/** A result object that may carry a configured cache-hint fallback. */ +interface CacheHintCarrier { + [RESULT_CACHE_HINT_FALLBACK]?: CacheHint; +} + +/** + * Attaches a configured cache hint to a result as the encode-time fallback. + * Returns the result unchanged when there is nothing to attach. When a more + * specific hint is already attached, the two hints are combined per field + * (most-specific-author-wins for each of `ttlMs` and `cacheScope`): the + * per-registration hint attached by the feature layer keeps every field it + * sets, and the server-level per-operation hint only fills the fields the + * more specific hint leaves unset. + */ +export function attachCacheHintFallback(result: T, hint: CacheHint | undefined): T { + if (hint === undefined) { + return result; + } + const attached = (result as CacheHintCarrier)[RESULT_CACHE_HINT_FALLBACK]; + if (attached === undefined) { + return { ...result, [RESULT_CACHE_HINT_FALLBACK]: hint }; + } + const merged: CacheHint = {}; + const ttlMs = attached.ttlMs ?? hint.ttlMs; + if (ttlMs !== undefined) { + merged.ttlMs = ttlMs; + } + const cacheScope = attached.cacheScope ?? hint.cacheScope; + if (cacheScope !== undefined) { + merged.cacheScope = cacheScope; + } + return { ...result, [RESULT_CACHE_HINT_FALLBACK]: merged }; +} + +/** Reads the configured cache-hint fallback attached to a result, if any. */ +export function cacheHintFallbackOf(result: object): CacheHint | undefined { + return (result as CacheHintCarrier)[RESULT_CACHE_HINT_FALLBACK]; +} + +/** + * Whether a value is a valid `ttlMs`: a non-negative safe integer. Safe + * integers are required because the wire schemas validate `ttlMs` as an + * integer within `Number.MIN_SAFE_INTEGER`/`Number.MAX_SAFE_INTEGER`; a value + * outside that range is treated as invalid here so it falls through to the + * next author instead of being emitted and rejected downstream. + */ +export function isValidCacheTtlMs(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +/** Whether a value is a valid `cacheScope`. */ +export function isValidCacheScope(value: unknown): value is CacheScope { + return value === 'public' || value === 'private'; +} + +/** + * Validates a configured cache hint at configuration time. Throws a + * `RangeError` naming the offending field, so misconfiguration fails at + * startup/registration rather than silently degrading at encode time. + */ +export function assertValidCacheHint(hint: CacheHint, context: string): void { + if (hint.ttlMs !== undefined && !isValidCacheTtlMs(hint.ttlMs)) { + throw new RangeError(`Invalid cache hint for ${context}: ttlMs must be a non-negative safe integer (got ${String(hint.ttlMs)})`); + } + if (hint.cacheScope !== undefined && !isValidCacheScope(hint.cacheScope)) { + throw new RangeError( + `Invalid cache hint for ${context}: cacheScope must be 'public' or 'private' (got ${String(hint.cacheScope)})` + ); + } +} diff --git a/packages/core/src/types/errors.ts b/packages/core/src/types/errors.ts index a175686d13..0ead600dbb 100644 --- a/packages/core/src/types/errors.ts +++ b/packages/core/src/types/errors.ts @@ -1,5 +1,10 @@ import { ProtocolErrorCode } from './enums.js'; -import type { ElicitRequestURLParams, UnsupportedProtocolVersionErrorData } from './types.js'; +import type { + ClientCapabilities, + ElicitRequestURLParams, + MissingRequiredClientCapabilityErrorData, + UnsupportedProtocolVersionErrorData +} from './types.js'; /** * Protocol errors are JSON-RPC errors that cross the wire as error responses. @@ -34,6 +39,17 @@ export class ProtocolError extends Error { } } + if (code === ProtocolErrorCode.MissingRequiredClientCapability && data) { + const errorData = data as Partial; + if ( + errorData.requiredCapabilities !== null && + typeof errorData.requiredCapabilities === 'object' && + !Array.isArray(errorData.requiredCapabilities) + ) { + return new MissingRequiredClientCapabilityError({ requiredCapabilities: errorData.requiredCapabilities }, message); + } + } + // Default to generic ProtocolError return new ProtocolError(code, message, data); } @@ -83,3 +99,34 @@ export class UnsupportedProtocolVersionError extends ProtocolError { return (this.data as UnsupportedProtocolVersionErrorData).requested; } } + +/** + * Error type for the `-32003` MissingRequiredClientCapability protocol error + * (protocol revision 2026-07-28): processing the request requires a capability + * the client did not declare in the request's `clientCapabilities`. + * + * The error data lists the missing capabilities (`requiredCapabilities`) in + * the `ClientCapabilities` shape, so the client can see exactly what it would + * have to declare for the request to be served. On HTTP, the response status + * is `400 Bad Request`. + * + * Recognize this error by its code and `data.requiredCapabilities` rather than + * by class identity (`instanceof` does not work across separately bundled + * copies of the SDK). + */ +export class MissingRequiredClientCapabilityError extends ProtocolError { + constructor( + data: MissingRequiredClientCapabilityErrorData, + message: string = `Missing required client capabilities: ${Object.keys(data.requiredCapabilities).join(', ')}` + ) { + super(ProtocolErrorCode.MissingRequiredClientCapability, message, data); + } + + /** + * The capabilities the server requires from the client to process the + * request (only the missing capabilities are listed). + */ + get requiredCapabilities(): ClientCapabilities { + return (this.data as MissingRequiredClientCapabilityErrorData).requiredCapabilities; + } +} diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index fced9eb501..92acc6a6ad 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -538,6 +538,19 @@ export interface InternalError extends JSONRPCErrorObject { code: typeof INTERNAL_ERROR; } +/** + * Data carried by a `-32003` MissingRequiredClientCapability protocol error + * (protocol revision 2026-07-28). + */ +export interface MissingRequiredClientCapabilityErrorData { + /** + * The capabilities the server requires from the client to process the + * request, in the `ClientCapabilities` shape (only the missing + * capabilities are listed). + */ + requiredCapabilities: ClientCapabilities; +} + /** * Data carried by a `-32004` UnsupportedProtocolVersion protocol error * (protocol revision 2026-07-28). diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index 72e13e3634..6ce2402cb8 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -144,10 +144,10 @@ export interface WireCodec { /** * Outbound result mapping (the stamp seam). The 2025-era codec is the * identity — it has NO stamp code path (the never-stamp guarantee). The - * 2026-era codec stamps `resultType` and strictly enforces the 2026 wire - * shape for the known deleted-field set (`execution.taskSupport`, - * `capabilities.tasks` — Q1-SD3 iii). ttlMs/cacheScope stamping content - * is M3.2 scope and lands in this seam. + * 2026-era codec strictly enforces the 2026 wire shape for the known + * deleted-field set (`execution.taskSupport`, `capabilities.tasks` — + * Q1-SD3 iii), stamps `resultType`, and fills the required + * `ttlMs`/`cacheScope` fields on cacheable results. */ encodeResult(method: string, result: Result): Result; diff --git a/packages/core/src/wire/rev2026-07-28/codec.ts b/packages/core/src/wire/rev2026-07-28/codec.ts index 9e3e4f25ef..4410a0a05b 100644 --- a/packages/core/src/wire/rev2026-07-28/codec.ts +++ b/packages/core/src/wire/rev2026-07-28/codec.ts @@ -5,12 +5,14 @@ * the RAW value is inspected BEFORE any schema validation, so a non-complete * result can never be masked into a hollow success by a tolerant schema), * then wire-exact parse, then lift (drop the wire member). Encode = the - * stamp seam: `resultType: 'complete'` is stamped on outbound results, and - * the known deleted-field set is strictly enforced (Q1-SD3 iii) — the 2026 - * wire types have no slot for `execution.taskSupport` or + * stamp seam: the known deleted-field set is strictly enforced (Q1-SD3 iii) — + * the 2026 wire types have no slot for `execution.taskSupport` or * `capabilities.tasks`, so the encode mapping deletes them; era-blind * handlers stay era-invisible while deleted vocabulary cannot cross eras - * through the parse-free outbound path. + * through the parse-free outbound path — and then the encode contract steps + * run (see `encodeContract.ts`): the `resultType` stamp (with handler + * pass-through for the multi round-trip methods) followed by the required + * `ttlMs`/`cacheScope` fill on cacheable results. * * Q1-SD3 postures implemented here: * (i) absent `resultType` from a 2026-classified peer → typed error NAMING @@ -22,15 +24,13 @@ * driver, M4.1/#13, consumes it; until then the protocol layer surfaces * the discriminated kind as a typed local error, no retry). * (iii) unrecognized kinds → invalid, no retry (DQ5). - * - * The ttlMs/cacheScope stamping content (M3.2) lands in `encodeResult` — - * this seam is its final home. */ import type * as z from 'zod/v4'; import { SdkError, SdkErrorCode } from '../../errors/sdkErrors.js'; import type { Result } from '../../types/types.js'; import type { DecodedResult, LiftedWireMaterial, WireCodec } from '../codec.js'; +import { fillCacheFields, stampResultType } from './encodeContract.js'; import { getNotificationSchema2026, getRequestSchema2026, @@ -172,10 +172,11 @@ export const rev2026Codec: WireCodec = { }, encodeResult(method: string, result: Result): Result { - // The stamp seam: outbound results carry the required discriminator. - // (Handler-authored resultType for methods whose vocabulary exceeds - // 'complete' is MRTR scope — #13 extends this seam.) - return { ...enforceDeletedFields(method, result), resultType: 'complete' } as Result; + // The stamp seam, in pinned order: deleted-field strictness, then the + // resultType stamp (handler pass-through only for methods whose + // vocabulary goes beyond 'complete'), then the cache fill for the + // cacheable operations (only on post-stamp 'complete' results). + return fillCacheFields(method, stampResultType(method, enforceDeletedFields(method, result))); }, checkInboundEnvelope(material: LiftedWireMaterial): string | undefined { diff --git a/packages/core/src/wire/rev2026-07-28/encodeContract.ts b/packages/core/src/wire/rev2026-07-28/encodeContract.ts new file mode 100644 index 0000000000..d03613aeeb --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/encodeContract.ts @@ -0,0 +1,127 @@ +/** + * The outbound result encode contract for the 2026-07-28 wire codec, as pure, + * individually-testable steps. `encodeResult` applies them in order: + * + * 1. {@linkcode stampResultType} — the `resultType` discriminator. The SDK + * stamps `'complete'`; a handler-provided value passes through only for + * methods whose spec result vocabulary goes beyond `'complete'` (the + * multi round-trip request methods, whose results may be + * `input_required`). A non-`'complete'` value returned by a handler for + * any other method is a server bug and fails loudly (internal error) + * rather than being mis-typed on the wire. + * 2. {@linkcode fillCacheFields} — the required `ttlMs`/`cacheScope` fields + * on cacheable results (SEP-2549), filled only when the post-stamp + * `resultType` is `'complete'` and the method is one of the cacheable + * operations. Resolution is most-specific-author-first: valid + * handler-returned values, then the configured cache hint attached by the + * server layer, then the conservative defaults + * `{ ttlMs: 0, cacheScope: 'private' }`. Invalid handler-returned values + * never reach the wire — they fall through to the next author. + * + * Ordering matters and is pinned by tests: the stamp runs before the fill, so + * an `input_required` result is never given cache fields. + */ +import type { CacheHint } from '../../shared/resultCacheHints.js'; +import { + cacheHintFallbackOf, + isCacheableResultMethod, + isValidCacheScope, + isValidCacheTtlMs, + RESULT_CACHE_HINT_FALLBACK +} from '../../shared/resultCacheHints.js'; +import { ProtocolErrorCode } from '../../types/enums.js'; +import { ProtocolError } from '../../types/errors.js'; +import type { Result } from '../../types/types.js'; + +/** The default cache policy when neither the handler nor configuration provides one. */ +export const DEFAULT_CACHE_TTL_MS = 0; +export const DEFAULT_CACHE_SCOPE = 'private'; + +/** + * Request methods whose spec result vocabulary goes beyond `'complete'` on the + * 2026-07-28 revision: their results may be `input_required` (multi + * round-trip requests), so a handler-provided `resultType` passes through the + * stamp untouched. `subscriptions/listen` joins this set when the + * subscriptions feature is served (its terminal result uses the same + * mechanism). + */ +export const EXTENDED_RESULT_TYPE_METHODS: readonly string[] = ['tools/call', 'prompts/get', 'resources/read']; + +/** + * Step 1 of the encode contract: ensure the outbound result carries the + * required `resultType` discriminator. + * + * - No handler-provided value → stamp `'complete'`. + * - Handler-provided `'complete'` → kept as-is. + * - Handler-provided non-`'complete'` value on a method whose vocabulary + * allows it ({@linkcode EXTENDED_RESULT_TYPE_METHODS}) → passes through. + * The value is forwarded verbatim — the wire vocabulary is an open union and + * the SDK does not validate the string, so emitting a `resultType` the + * negotiated revision does not define is the handler author's + * responsibility. + * - Handler-provided non-`'complete'` value on any other method → internal + * error (loud): the value would be mis-typed on the wire, and silently + * rewriting it would hide a server bug. + */ +export function stampResultType(method: string, result: Result): Result { + const provided = (result as Record)['resultType']; + if (provided === undefined) { + return { ...result, resultType: 'complete' } as Result; + } + if (provided === 'complete') { + return result; + } + if (EXTENDED_RESULT_TYPE_METHODS.includes(method)) { + return result; + } + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned resultType '${String(provided)}', but results of ${method} only support 'complete' on protocol revision 2026-07-28` + ); +} + +/** + * Step 2 of the encode contract: fill the required `ttlMs`/`cacheScope` fields + * on cacheable results. + * + * Applies only when the (post-stamp) `resultType` is `'complete'` and the + * method is one of the cacheable operations; everything else is returned + * untouched apart from removing the configured-hint carrier. Field resolution + * is per field, most specific author first: a valid handler-returned value, + * then the configured cache hint attached by the server layer, then the + * defaults. Handler-returned values are validated at encode time (`ttlMs` + * must be a non-negative integer, `cacheScope` must be `'public'` or + * `'private'`); invalid values are ignored rather than emitted. + */ +export function fillCacheFields(method: string, result: Result): Result { + const fallback = cacheHintFallbackOf(result); + const resultType = (result as Record)['resultType']; + + if (resultType !== 'complete' || !isCacheableResultMethod(method)) { + // Not a cache-fill target. Drop the configured-hint carrier if one was + // attached so it never travels past the encode seam. + return fallback === undefined ? result : stripCacheHintFallback(result); + } + + const provided = result as Record; + const ttlMs = isValidCacheTtlMs(provided['ttlMs']) ? (provided['ttlMs'] as number) : resolveTtlMs(fallback); + const cacheScope = isValidCacheScope(provided['cacheScope']) ? (provided['cacheScope'] as string) : resolveCacheScope(fallback); + + const filled = { ...provided, ttlMs, cacheScope } as Record; + delete filled[RESULT_CACHE_HINT_FALLBACK]; + return filled as Result; +} + +function resolveTtlMs(fallback: CacheHint | undefined): number { + return fallback !== undefined && isValidCacheTtlMs(fallback.ttlMs) ? fallback.ttlMs : DEFAULT_CACHE_TTL_MS; +} + +function resolveCacheScope(fallback: CacheHint | undefined): string { + return fallback !== undefined && isValidCacheScope(fallback.cacheScope) ? fallback.cacheScope : DEFAULT_CACHE_SCOPE; +} + +function stripCacheHintFallback(result: Result): Result { + const copy = { ...result } as Record; + delete copy[RESULT_CACHE_HINT_FALLBACK]; + return copy as Result; +} diff --git a/packages/core/test/shared/clientCapabilityRequirements.test.ts b/packages/core/test/shared/clientCapabilityRequirements.test.ts new file mode 100644 index 0000000000..9b4c607586 --- /dev/null +++ b/packages/core/test/shared/clientCapabilityRequirements.test.ts @@ -0,0 +1,60 @@ +/** + * The shared client-capability requirement helpers behind the `-32003` + * MissingRequiredClientCapability rule (protocol revision 2026-07-28). + * + * `missingClientCapabilities` is the single helper shared by the pre-dispatch + * feature gate at the HTTP entry, the outbound input-request leg of multi + * round-trip requests, and the legacy-session pre-check; the per-method + * requirement table feeds the entry gate only. + */ +import { describe, expect, test } from 'vitest'; + +import { + missingClientCapabilities, + REQUIRED_CLIENT_CAPABILITIES_BY_METHOD, + requiredClientCapabilitiesForRequest +} from '../../src/shared/clientCapabilityRequirements.js'; +import { rev2026RequestMethods } from '../../src/wire/rev2026-07-28/registry.js'; + +describe('missingClientCapabilities', () => { + test('an undeclared capability view (no envelope, empty session state) misses everything required — the structural clean refusal', () => { + expect(missingClientCapabilities({ sampling: {} }, undefined)).toEqual({ sampling: {} }); + expect(missingClientCapabilities({ sampling: {}, elicitation: {} }, {})).toEqual({ sampling: {}, elicitation: {} }); + }); + + test('declared top-level capabilities satisfy top-level requirements', () => { + expect(missingClientCapabilities({ sampling: {} }, { sampling: {} })).toBeUndefined(); + }); + + test('only the missing subset is reported', () => { + expect(missingClientCapabilities({ sampling: {}, elicitation: {} }, { sampling: {} })).toEqual({ elicitation: {} }); + }); + + test('a requirement naming nested members needs each named member declared', () => { + expect(missingClientCapabilities({ elicitation: { url: {} } }, { elicitation: {} })).toEqual({ elicitation: { url: {} } }); + expect(missingClientCapabilities({ elicitation: { url: {} } }, { elicitation: { url: {} } })).toBeUndefined(); + expect(missingClientCapabilities({ elicitation: { url: {} } }, { elicitation: { form: {}, url: {} } })).toBeUndefined(); + }); + + test('an empty requirement object is always satisfied', () => { + expect(missingClientCapabilities({}, undefined)).toBeUndefined(); + }); +}); + +describe('requiredClientCapabilitiesForRequest', () => { + test('no method served on the 2026-07-28 registry has a static capability requirement today (the table is empty)', () => { + // This pin burns when a request method with a structural client-capability + // requirement is added (for example by the input-request engine or opt-in + // subscription delivery): add the entry, then update this expectation and + // cover the new cell. + expect(Object.keys(REQUIRED_CLIENT_CAPABILITIES_BY_METHOD)).toEqual([]); + for (const method of rev2026RequestMethods) { + expect(requiredClientCapabilitiesForRequest(method)).toBeUndefined(); + } + }); + + test('prototype-chain names never resolve to a requirement', () => { + expect(requiredClientCapabilitiesForRequest('constructor')).toBeUndefined(); + expect(requiredClientCapabilitiesForRequest('hasOwnProperty')).toBeUndefined(); + }); +}); diff --git a/packages/core/test/shared/errorHttpStatusMatrix.test.ts b/packages/core/test/shared/errorHttpStatusMatrix.test.ts new file mode 100644 index 0000000000..6cab1c46d0 --- /dev/null +++ b/packages/core/test/shared/errorHttpStatusMatrix.test.ts @@ -0,0 +1,98 @@ +/** + * The error→HTTP status matrix for the modern (2026-07-28) HTTP serving path, + * pinned at the table level (`LADDER_ERROR_HTTP_STATUS` / + * `httpStatusForErrorCode`). The mapping is keyed on ORIGIN, not on the bare + * code: + * + * - errors produced by the validation ladder or a pre-handler protocol gate + * map through the table (`-32601` → 404; the small mandated 400 set); + * - everything a request handler produces — whatever its code, including + * `-32603`, `-32602` and domain-specific codes — stays in-band on HTTP 200, + * never a blanket 500; + * - `-32602` deliberately has no table entry: the classifier's envelope rung + * carries its own HTTP 400 and is the only invalid-params rejection that + * maps to 400. + * + * Cells whose error CODE is still disputed upstream (the header/body mismatch + * family) stay parameterized: the emitted code is asserted as candidate-set + * membership, never a pinned literal. + * + * Transport- and dispatch-level behavior for these cells is covered by the + * ladder cell sheet and the per-request transport suites; this file pins the + * table itself. + */ +import { describe, expect, test } from 'vitest'; + +import { + httpStatusForErrorCode, + LADDER_ERROR_HTTP_STATUS, + PROVISIONAL_CROSS_CHECK_MISMATCH_CODE +} from '../../src/shared/inboundClassification.js'; +import { ProtocolErrorCode } from '../../src/types/enums.js'; + +describe('the status matrix — pinned cells', () => { + const PINNED_LADDER_CELLS: ReadonlyArray<{ code: number; status: number; cell: string }> = [ + { + code: ProtocolErrorCode.MethodNotFound, + status: 404, + cell: 'unknown or era-removed method (including a post-dispatch registry miss)' + }, + { code: ProtocolErrorCode.UnsupportedProtocolVersion, status: 400, cell: 'unsupported protocol version' }, + { code: ProtocolErrorCode.MissingRequiredClientCapability, status: 400, cell: 'missing required client capability' }, + { code: -32_001, status: 400, cell: 'header mismatch family (when emitted by the ladder)' }, + { code: ProtocolErrorCode.ParseError, status: 400, cell: 'unparseable request body' }, + { code: ProtocolErrorCode.InvalidRequest, status: 400, cell: 'malformed JSON-RPC body / rejected batch' } + ]; + + test.each(PINNED_LADDER_CELLS.map(row => [row.cell, row]))('%s', (_cell, row) => { + expect(LADDER_ERROR_HTTP_STATUS[row.code]).toBe(row.status); + expect(httpStatusForErrorCode(row.code, 'ladder')).toBe(row.status); + }); + + test('every code stays in-band on HTTP 200 when handler-originated — including internal errors and domain codes', () => { + const handlerCodes = [ + ProtocolErrorCode.InternalError, + ProtocolErrorCode.InvalidParams, + ProtocolErrorCode.MethodNotFound, + ProtocolErrorCode.ResourceNotFound, + ProtocolErrorCode.UrlElicitationRequired, + -32_000, + -1, + 12_345 + ]; + for (const code of handlerCodes) { + expect(httpStatusForErrorCode(code, 'in-band')).toBe(200); + } + }); + + test('-32603 never becomes a blanket 500: handler-originated internal errors are in-band', () => { + expect(LADDER_ERROR_HTTP_STATUS[ProtocolErrorCode.InternalError]).toBeUndefined(); + expect(httpStatusForErrorCode(ProtocolErrorCode.InternalError, 'in-band')).toBe(200); + }); + + test('-32602 has no table entry: the envelope rung short-circuit is the only invalid-params source of HTTP 400', () => { + expect(LADDER_ERROR_HTTP_STATUS[ProtocolErrorCode.InvalidParams]).toBeUndefined(); + expect(httpStatusForErrorCode(ProtocolErrorCode.InvalidParams, 'in-band')).toBe(200); + }); + + test('the table is exactly the mandated set (no silent growth)', () => { + expect( + Object.keys(LADDER_ERROR_HTTP_STATUS) + .map(Number) + .sort((a, b) => a - b) + ).toEqual([-32_700, -32_601, -32_600, -32_004, -32_003, -32_001].sort((a, b) => a - b)); + }); +}); + +describe('the status matrix — parameterized (disputed) cells', () => { + test('the header/body mismatch family code is a candidate, not a pin, and maps to 400 whichever candidate it is', () => { + const candidates = [-32_001, ProtocolErrorCode.InvalidParams, ProtocolErrorCode.UnsupportedProtocolVersion]; + expect(candidates).toContain(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE); + // Whatever the upstream resolution turns out to be, a ladder-originated + // rejection in this family answers HTTP 400: every candidate either has + // a 400 row or is carried by the classifier's own httpStatus. + if (PROVISIONAL_CROSS_CHECK_MISMATCH_CODE !== ProtocolErrorCode.InvalidParams) { + expect(httpStatusForErrorCode(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE, 'ladder')).toBe(400); + } + }); +}); diff --git a/packages/core/test/shared/inboundLadderCellSheet.test.ts b/packages/core/test/shared/inboundLadderCellSheet.test.ts index d1e661543b..9eedf58f52 100644 --- a/packages/core/test/shared/inboundLadderCellSheet.test.ts +++ b/packages/core/test/shared/inboundLadderCellSheet.test.ts @@ -432,8 +432,14 @@ describe('the validation ladder as data', () => { describe('HTTP status mapping for ladder-originated errors (origin-keyed)', () => { test('the table maps exactly the ladder-originated codes', () => { + // The parse-error and invalid-request rows joined the table when the + // status matrix was completed alongside the cache fill / capability + // gate work; they were previously carried only by the classifier's own + // httpStatus on the rejection outcomes (same 400, now table-visible). expect(LADDER_ERROR_HTTP_STATUS).toEqual({ + [-32_700]: 400, [-32_601]: 404, + [-32_600]: 400, [-32_004]: 400, [-32_003]: 400, [-32_001]: 400 diff --git a/packages/core/test/types/missingClientCapabilityError.test.ts b/packages/core/test/types/missingClientCapabilityError.test.ts new file mode 100644 index 0000000000..1d15ad1dae --- /dev/null +++ b/packages/core/test/types/missingClientCapabilityError.test.ts @@ -0,0 +1,64 @@ +/** + * The `-32003` MissingRequiredClientCapability typed error. + * + * Recognition is data-parse based: a peer (or another bundled copy of the SDK) + * is recognized by the error code plus the `data.requiredCapabilities` shape, + * never by `instanceof` across bundles. + */ +import { describe, expect, test } from 'vitest'; + +import { ProtocolErrorCode } from '../../src/types/enums.js'; +import { MissingRequiredClientCapabilityError, ProtocolError } from '../../src/types/errors.js'; + +describe('MissingRequiredClientCapabilityError', () => { + test('carries the -32003 code and the missing capabilities in data.requiredCapabilities', () => { + const error = new MissingRequiredClientCapabilityError({ requiredCapabilities: { sampling: {}, elicitation: { url: {} } } }); + expect(error.code).toBe(ProtocolErrorCode.MissingRequiredClientCapability); + expect(error.code).toBe(-32_003); + expect(error.requiredCapabilities).toEqual({ sampling: {}, elicitation: { url: {} } }); + expect(error.data).toEqual({ requiredCapabilities: { sampling: {}, elicitation: { url: {} } } }); + expect(error.message).toContain('sampling'); + expect(error.message).toContain('elicitation'); + }); + + test('a custom message is preserved', () => { + const error = new MissingRequiredClientCapabilityError({ requiredCapabilities: { sampling: {} } }, 'declare sampling first'); + expect(error.message).toBe('declare sampling first'); + }); + + test('fromError recognizes the code + data shape (the cross-bundle data-parse path)', () => { + // Simulates an error received from the wire or from a separately + // bundled SDK copy: plain code/message/data, no class identity. + const wireShape = { + code: -32_003, + message: 'Missing required client capabilities: sampling', + data: { requiredCapabilities: { sampling: {} } } + }; + const recognized = ProtocolError.fromError(wireShape.code, wireShape.message, wireShape.data); + expect(recognized).toBeInstanceOf(MissingRequiredClientCapabilityError); + expect((recognized as MissingRequiredClientCapabilityError).requiredCapabilities).toEqual({ sampling: {} }); + }); + + test('fromError falls back to the generic ProtocolError when the data shape does not match', () => { + expect(ProtocolError.fromError(-32_003, 'missing', undefined)).not.toBeInstanceOf(MissingRequiredClientCapabilityError); + expect(ProtocolError.fromError(-32_003, 'missing', { requiredCapabilities: ['sampling'] })).not.toBeInstanceOf( + MissingRequiredClientCapabilityError + ); + expect(ProtocolError.fromError(-32_003, 'missing', { somethingElse: true })).not.toBeInstanceOf( + MissingRequiredClientCapabilityError + ); + expect(ProtocolError.fromError(-32_003, 'missing', { requiredCapabilities: { sampling: {} } })).toBeInstanceOf( + MissingRequiredClientCapabilityError + ); + }); + + test('recognition by code and data shape works on plain values (no instanceof needed)', () => { + const fromAnotherBundle: { code: number; data?: unknown } = new MissingRequiredClientCapabilityError({ + requiredCapabilities: { sampling: {} } + }); + const looksLikeMissingCapability = + fromAnotherBundle.code === -32_003 && + typeof (fromAnotherBundle.data as { requiredCapabilities?: unknown } | undefined)?.requiredCapabilities === 'object'; + expect(looksLikeMissingCapability).toBe(true); + }); +}); diff --git a/packages/core/test/wire/encodeContract.test.ts b/packages/core/test/wire/encodeContract.test.ts new file mode 100644 index 0000000000..572376fb01 --- /dev/null +++ b/packages/core/test/wire/encodeContract.test.ts @@ -0,0 +1,201 @@ +/** + * The 2026-07-28 outbound encode contract, tested as pure steps and through + * the codec's `encodeResult` integration: + * + * step 1 — resultType stamp: `'complete'` stamped when absent; a + * handler-provided value passes through only for methods whose spec + * result vocabulary goes beyond `'complete'` (the multi round-trip + * methods); a stray non-`'complete'` value anywhere else fails + * loudly instead of being mis-typed on the wire. + * step 2 — cache fill: `ttlMs`/`cacheScope` filled only on post-stamp + * `'complete'` results of the cacheable operations, resolved most + * specific author first (valid handler-returned values, then the + * attached configured hint, then the defaults), with an encode-time + * validity gate on handler-returned values. + * + * The ordering (stamp before fill, `input_required` excluded from the fill) + * is pinned here. + */ +import { describe, expect, test } from 'vitest'; + +import { + attachCacheHintFallback, + CACHEABLE_RESULT_METHODS, + cacheHintFallbackOf, + RESULT_CACHE_HINT_FALLBACK +} from '../../src/shared/resultCacheHints.js'; +import { ProtocolError } from '../../src/types/errors.js'; +import type { Result } from '../../src/types/types.js'; +import { rev2026Codec } from '../../src/wire/rev2026-07-28/codec.js'; +import { + DEFAULT_CACHE_SCOPE, + DEFAULT_CACHE_TTL_MS, + EXTENDED_RESULT_TYPE_METHODS, + fillCacheFields, + stampResultType +} from '../../src/wire/rev2026-07-28/encodeContract.js'; + +const asResult = (value: Record): Result => value as unknown as Result; +const fieldsOf = (value: Result): Record => value as unknown as Record; + +describe('step 1 — the resultType stamp', () => { + test("stamps 'complete' when the handler did not provide a resultType", () => { + const stamped = fieldsOf(stampResultType('tools/list', asResult({ tools: [] }))); + expect(stamped['resultType']).toBe('complete'); + }); + + test("keeps a handler-provided 'complete' as-is (same reference)", () => { + const result = asResult({ tools: [], resultType: 'complete' }); + expect(stampResultType('tools/list', result)).toBe(result); + }); + + test.each(EXTENDED_RESULT_TYPE_METHODS.map(method => [method]))( + 'passes a handler-provided input_required through for %s (extended result vocabulary)', + method => { + const result = asResult({ resultType: 'input_required', inputRequests: {} }); + expect(stampResultType(method, result)).toBe(result); + } + ); + + test('passes other handler-provided values through on extended-vocabulary methods (the wire vocabulary is an open union)', () => { + const result = asResult({ resultType: 'some_future_kind' }); + expect(stampResultType('tools/call', result)).toBe(result); + }); + + test.each([['tools/list'], ['prompts/list'], ['server/discover'], ['completion/complete']])( + 'a stray input_required from a handler for %s fails loudly with an internal error', + method => { + expect(() => stampResultType(method, asResult({ resultType: 'input_required' }))).toThrowError(ProtocolError); + try { + stampResultType(method, asResult({ resultType: 'input_required' })); + } catch (error) { + expect((error as ProtocolError).code).toBe(-32_603); + expect((error as ProtocolError).message).toContain(method); + } + } + ); + + test('the extended-vocabulary method set is exactly the multi round-trip request methods', () => { + expect([...EXTENDED_RESULT_TYPE_METHODS].sort()).toEqual(['prompts/get', 'resources/read', 'tools/call'].sort()); + }); +}); + +describe('step 2 — the cache fill', () => { + test('the cacheable-operation list is closed at exactly six operations', () => { + expect([...CACHEABLE_RESULT_METHODS].sort()).toEqual( + ['tools/list', 'prompts/list', 'resources/list', 'resources/templates/list', 'resources/read', 'server/discover'].sort() + ); + }); + + test.each(CACHEABLE_RESULT_METHODS.map(method => [method]))('fills the defaults on a complete %s result', method => { + const filled = fieldsOf(fillCacheFields(method, asResult({ resultType: 'complete' }))); + expect(filled['ttlMs']).toBe(DEFAULT_CACHE_TTL_MS); + expect(filled['cacheScope']).toBe(DEFAULT_CACHE_SCOPE); + }); + + test.each([['tools/call'], ['prompts/get'], ['completion/complete'], ['app/custom']])( + 'never fills cache fields for %s (not a cacheable operation)', + method => { + const filled = fieldsOf(fillCacheFields(method, asResult({ resultType: 'complete' }))); + expect('ttlMs' in filled).toBe(false); + expect('cacheScope' in filled).toBe(false); + } + ); + + test('input_required results are never given cache fields (stamp-before-fill ordering)', () => { + const filled = fieldsOf(fillCacheFields('resources/read', asResult({ resultType: 'input_required', inputRequests: {} }))); + expect('ttlMs' in filled).toBe(false); + expect('cacheScope' in filled).toBe(false); + }); + + test('valid handler-returned values are respected over the attached hint and the defaults', () => { + const result = attachCacheHintFallback(asResult({ resultType: 'complete', ttlMs: 30_000, cacheScope: 'public' }), { + ttlMs: 5_000, + cacheScope: 'private' + }); + const filled = fieldsOf(fillCacheFields('tools/list', result)); + expect(filled['ttlMs']).toBe(30_000); + expect(filled['cacheScope']).toBe('public'); + }); + + test('the attached configured hint wins over the defaults when the handler provided nothing', () => { + const result = attachCacheHintFallback(asResult({ resultType: 'complete' }), { ttlMs: 5_000, cacheScope: 'public' }); + const filled = fieldsOf(fillCacheFields('resources/read', result)); + expect(filled['ttlMs']).toBe(5_000); + expect(filled['cacheScope']).toBe('public'); + }); + + test('a partial hint fills only its own field; the other falls back to the default', () => { + const result = attachCacheHintFallback(asResult({ resultType: 'complete' }), { ttlMs: 9_000 }); + const filled = fieldsOf(fillCacheFields('server/discover', result)); + expect(filled['ttlMs']).toBe(9_000); + expect(filled['cacheScope']).toBe(DEFAULT_CACHE_SCOPE); + }); + + test.each([ + ['a negative ttlMs', { ttlMs: -1 }], + ['a non-integer ttlMs', { ttlMs: 1.5 }], + ['an unsafe-integer ttlMs (above 2^53 - 1, rejected by the wire schemas)', { ttlMs: 1e20 }], + ['a NaN ttlMs', { ttlMs: Number.NaN }], + ['an infinite ttlMs', { ttlMs: Number.POSITIVE_INFINITY }], + ['a non-numeric ttlMs', { ttlMs: 'soon' }], + ['an unknown cacheScope', { cacheScope: 'shared' }] + ])('invalid handler-returned values (%s) never reach the wire — the next author wins', (_label, invalid) => { + const result = attachCacheHintFallback(asResult({ resultType: 'complete', ...invalid }), { ttlMs: 1_000, cacheScope: 'public' }); + const filled = fieldsOf(fillCacheFields('tools/list', result)); + expect(filled['ttlMs']).toBe(1_000); + expect(filled['cacheScope']).toBe('public'); + }); + + test('the configured-hint carrier never survives past the encode seam', () => { + const filledTarget = fillCacheFields('tools/list', attachCacheHintFallback(asResult({ resultType: 'complete' }), { ttlMs: 1 })); + expect(cacheHintFallbackOf(filledTarget)).toBeUndefined(); + + const nonTarget = fillCacheFields('tools/call', attachCacheHintFallback(asResult({ resultType: 'complete' }), { ttlMs: 1 })); + expect(cacheHintFallbackOf(nonTarget)).toBeUndefined(); + expect(RESULT_CACHE_HINT_FALLBACK in (nonTarget as object)).toBe(false); + }); + + test('attachCacheHintFallback never overwrites an already-attached, more specific hint', () => { + const withSpecific = attachCacheHintFallback(asResult({}), { ttlMs: 2_000 }); + const withBoth = attachCacheHintFallback(withSpecific, { ttlMs: 50 }); + expect(cacheHintFallbackOf(withBoth)).toEqual({ ttlMs: 2_000 }); + }); + + test('attachCacheHintFallback combines hints per field: a less specific hint fills only the fields the attached hint leaves unset', () => { + const withSpecific = attachCacheHintFallback(asResult({}), { cacheScope: 'public' }); + const withBoth = attachCacheHintFallback(withSpecific, { ttlMs: 50, cacheScope: 'private' }); + expect(cacheHintFallbackOf(withBoth)).toEqual({ ttlMs: 50, cacheScope: 'public' }); + }); +}); + +describe('the codec integration (encodeResult applies the contract in pinned order)', () => { + test('a complete cacheable result is stamped and filled', () => { + const encoded = fieldsOf(rev2026Codec.encodeResult('tools/list', asResult({ tools: [] }))); + expect(encoded).toMatchObject({ resultType: 'complete', ttlMs: DEFAULT_CACHE_TTL_MS, cacheScope: DEFAULT_CACHE_SCOPE }); + }); + + test('deleted-field strictness, stamp and fill compose on the same emission', () => { + const encoded = fieldsOf( + rev2026Codec.encodeResult( + 'tools/list', + asResult({ tools: [{ name: 't', inputSchema: { type: 'object' }, execution: { taskSupport: 'optional' } }] }) + ) + ); + expect(encoded).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' }); + expect('execution' in (encoded['tools'] as Array>)[0]!).toBe(false); + }); + + test('an input_required result from a multi round-trip method is passed through unfilled', () => { + const encoded = fieldsOf( + rev2026Codec.encodeResult('resources/read', asResult({ resultType: 'input_required', inputRequests: {} })) + ); + expect(encoded['resultType']).toBe('input_required'); + expect('ttlMs' in encoded).toBe(false); + expect('cacheScope' in encoded).toBe(false); + }); + + test('a stray input_required from a non-multi-round-trip handler throws out of encodeResult (answered as an internal error upstream)', () => { + expect(() => rev2026Codec.encodeResult('tools/list', asResult({ resultType: 'input_required' }))).toThrowError(ProtocolError); + }); +}); diff --git a/packages/core/test/wire/stampingSuppression.test.ts b/packages/core/test/wire/stampingSuppression.test.ts new file mode 100644 index 0000000000..80af02aacc --- /dev/null +++ b/packages/core/test/wire/stampingSuppression.test.ts @@ -0,0 +1,270 @@ +/** + * The stamping suppression suite: what is NEVER stamped. + * + * S1 — legacy-classified traffic is never stamped (structural: the 2025-era + * codec has no stamp or cache code path; encode is the identity). + * S2 — input_required results never carry cache fields. + * S3 — results of non-cacheable operations are never given cache fields; the + * cacheable-operation list is closed. + * S4 — era-removed (2025-only) methods are never stamped: they have no + * 2026-era registry entry, so they can never reach the 2026 encode + * seam, and their 2025-era responses are byte-untouched. + * S5 — stamping is response-side only: requests emitted by a 2026-era sender + * carry none of the result vocabulary. + * S6 — error responses are never stamped. + * + * Carve-out (documented leak note): cache fields AUTHORED BY THE CONSUMER on a + * 2025-era result pass through unchanged — the suite asserts the absence of + * SDK-stamped vocabulary only, because stripping consumer-authored fields + * would change deployed 2025-era behavior for no gain. + * + * Together with the 2025 codec identity pin, this suite is the evidence that + * this change produces zero 2025-era wire deltas. + */ +import { describe, expect, test } from 'vitest'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import { attachCacheHintFallback, CACHEABLE_RESULT_METHODS } from '../../src/shared/resultCacheHints.js'; +import type { JSONRPCMessage, MessageClassification, Result } from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import { rev2025Codec } from '../../src/wire/rev2025-11-25/codec.js'; +import { rev2026Codec } from '../../src/wire/rev2026-07-28/codec.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const MODERN: MessageClassification = { era: 'modern', revision: '2026-07-28' }; + +const ENVELOPE = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'suppression-client', version: '0.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +/** The SDK-stamped result vocabulary the 2025 era must never gain. */ +const STAMPED_VOCABULARY = ['resultType', 'ttlMs', 'cacheScope'] as const; + +interface Harness { + receiver: TestProtocol; + deliver: (message: JSONRPCMessage, classification?: MessageClassification) => void; + sent: JSONRPCMessage[]; + flush: () => Promise; +} + +async function harness(options: { era?: '2026-07-28'; setup?: (receiver: TestProtocol) => void } = {}): Promise { + const [peerTx, receiverTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const receiver = new TestProtocol(); + receiver.onerror = () => {}; + options.setup?.(receiver); + if (options.era !== undefined) setNegotiatedProtocolVersion(receiver, options.era); + await receiver.connect(receiverTx); + + return { + receiver, + deliver: (message, classification) => receiverTx.onmessage?.(message, classification ? ({ classification } as never) : undefined), + sent, + flush: () => new Promise(resolve => setTimeout(resolve, 10)) + }; +} + +const resultOf = (msg: JSONRPCMessage | undefined) => (msg as { result?: Record } | undefined)?.result; +const errorOf = (msg: JSONRPCMessage | undefined) => (msg as { error?: { code: number; data?: unknown } } | undefined)?.error; + +function expectNoStampedVocabulary(value: unknown): void { + const json = JSON.stringify(value); + for (const key of STAMPED_VOCABULARY) { + expect(json).not.toContain(`"${key}"`); + } +} + +describe('S1 — legacy-classified traffic is never stamped', () => { + test('the 2025 codec encode is the identity for every cacheable operation, even with a configured hint attached', () => { + for (const method of CACHEABLE_RESULT_METHODS) { + const plain = { items: [] } as unknown as Result; + expect(rev2025Codec.encodeResult(method, plain)).toBe(plain); + + const withHint = attachCacheHintFallback({ items: [] } as unknown as Result, { ttlMs: 60_000, cacheScope: 'public' }); + const encoded = rev2025Codec.encodeResult(method, withHint); + expect(encoded).toBe(withHint); + expectNoStampedVocabulary(encoded); + } + }); + + test('a 2025-era (unclassified) tools/list exchange carries none of the stamped vocabulary', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + expect(resultOf(h.sent[0])).toEqual({ tools: [] }); + expectNoStampedVocabulary(h.sent[0]); + }); +}); + +describe('S2 — input_required results never carry cache fields', () => { + test('an input_required resources/read result on the 2026 era is emitted without ttlMs/cacheScope', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('resources/read', (() => ({ resultType: 'input_required', inputRequests: {} })) as never); + } + }); + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'test://a', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + const result = resultOf(h.sent[0]); + expect(result?.['resultType']).toBe('input_required'); + expect(result !== undefined && 'ttlMs' in result).toBe(false); + expect(result !== undefined && 'cacheScope' in result).toBe(false); + }); +}); + +describe('S3 — non-cacheable operations are never filled', () => { + test('the cacheable-operation list is closed (six operations; call/get/complete results are excluded)', () => { + expect([...CACHEABLE_RESULT_METHODS].sort()).toEqual( + ['prompts/list', 'resources/list', 'resources/read', 'resources/templates/list', 'server/discover', 'tools/list'].sort() + ); + expect(CACHEABLE_RESULT_METHODS).not.toContain('tools/call'); + expect(CACHEABLE_RESULT_METHODS).not.toContain('prompts/get'); + expect(CACHEABLE_RESULT_METHODS).not.toContain('completion/complete'); + }); + + test('a 2026-era tools/call result is stamped but never given cache fields', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/call', () => ({ content: [] })); + } + }); + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 't', arguments: {}, _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + const result = resultOf(h.sent[0]); + expect(result?.['resultType']).toBe('complete'); + expect(result !== undefined && 'ttlMs' in result).toBe(false); + expect(result !== undefined && 'cacheScope' in result).toBe(false); + }); +}); + +describe('S4 — era-removed (2025-only) methods are never stamped', () => { + const LEGACY_ONLY_EMPTY_RESULT_CARRIERS = ['ping', 'logging/setLevel', 'resources/subscribe', 'resources/unsubscribe'] as const; + + test('the 2026-era registry has no entry for the 2025-only EmptyResult carriers (they can never reach the 2026 encode seam)', () => { + for (const method of [...LEGACY_ONLY_EMPTY_RESULT_CARRIERS, 'initialize']) { + expect(rev2026Codec.hasRequestMethod(method)).toBe(false); + } + }); + + test('a 2025-era ping answer (EmptyResult) carries none of the stamped vocabulary', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('ping', () => ({})); + } + }); + h.deliver({ jsonrpc: '2.0', id: 1, method: 'ping' } as JSONRPCMessage); + await h.flush(); + expect(resultOf(h.sent[0])).toEqual({}); + expectNoStampedVocabulary(h.sent[0]); + }); + + test('a 2026-era instance answers an era-removed method with method-not-found and no stamped vocabulary', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('ping', () => ({})); + } + }); + h.deliver({ jsonrpc: '2.0', id: 1, method: 'ping', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + expect(errorOf(h.sent[0])?.code).toBe(-32_601); + expectNoStampedVocabulary(h.sent[0]); + }); +}); + +describe('S5 — stamping is response-side only', () => { + test('a request emitted by a 2026-era sender carries none of the result vocabulary', async () => { + const [peerTx, senderTx] = InMemoryTransport.createLinkedPair(); + const requests: JSONRPCMessage[] = []; + peerTx.onmessage = message => { + requests.push(message); + const request = message as { id?: number | string; method?: string }; + if (request.id !== undefined && request.method === 'server/discover') { + void peerTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + supportedVersions: ['2026-07-28'], + capabilities: {}, + serverInfo: { name: 'peer', version: '0.0.0' } + } + } as JSONRPCMessage); + } + }; + await peerTx.start(); + + const sender = new TestProtocol(); + setNegotiatedProtocolVersion(sender, '2026-07-28'); + await sender.connect(senderTx); + + await sender.request({ method: 'server/discover' }); + + expect(requests).toHaveLength(1); + expectNoStampedVocabulary(requests[0]); + await sender.close(); + }); +}); + +describe('S6 — error responses are never stamped', () => { + test('a handler-thrown error on the 2026 era is emitted without any result vocabulary', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => { + throw Object.assign(new Error('nope'), { code: -32_602 }); + }); + } + }); + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + expect(errorOf(h.sent[0])?.code).toBe(-32_602); + expectNoStampedVocabulary(h.sent[0]); + }); +}); + +describe('the consumer-authored carve-out (documented leak note)', () => { + test('cache fields authored by a consumer handler on the 2025 era pass through unchanged — only SDK-stamped vocabulary is asserted absent', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', (() => ({ tools: [], ttlMs: 5_000, cacheScope: 'public' })) as never); + } + }); + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + const result = resultOf(h.sent[0]); + // Pass-through, byte-for-byte what the handler authored: stripping it + // would change deployed 2025-era behavior. The negative-vocabulary + // assertions in this suite therefore target SDK-stamped values only. + expect(result).toEqual({ tools: [], ttlMs: 5_000, cacheScope: 'public' }); + expect(result !== undefined && 'resultType' in result).toBe(false); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2a1e272d43..76244d12b3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -71,5 +71,9 @@ export type { } from '@modelcontextprotocol/core'; export { classifyInboundRequest } from '@modelcontextprotocol/core'; +// Cache hints for cacheable 2026-07-28 results (ServerOptions.cacheHints and +// the registerResource cacheHint option). +export type { CacheHint, CacheScope } from '@modelcontextprotocol/core'; + // re-export curated public API from core export * from '@modelcontextprotocol/core/public'; diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index a3341c5fed..00f35ddeac 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -34,8 +34,12 @@ import { classifyInboundRequest, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + httpStatusForErrorCode, + missingClientCapabilities, + MissingRequiredClientCapabilityError, modernOnlyStrictRejection, requestMetaOf, + requiredClientCapabilitiesForRequest, SdkError, SdkErrorCode, setNegotiatedProtocolVersion, @@ -420,6 +424,33 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return jsonRpcErrorResponse(400, error.code, error.message, error.data, echoableRequestId(message)); } + const meta = route.messageKind === 'request' ? requestMetaOf((message as JSONRPCRequest).params) : undefined; + const declaredClientCapabilities = meta?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; + + // Pre-dispatch capability gate: a request to a method whose processing + // structurally requires a client capability the request's validated + // envelope did not declare is refused here, before any instance is + // constructed or dispatched. Answering at the entry pins the + // spec-mandated HTTP 400 for this error; a handler-time emission would + // surface in-band on HTTP 200. + if (route.messageKind === 'request') { + const required = requiredClientCapabilitiesForRequest((message as JSONRPCRequest).method); + if (required !== undefined) { + const missing = missingClientCapabilities(required, declaredClientCapabilities); + if (missing !== undefined) { + const error = new MissingRequiredClientCapabilityError({ requiredCapabilities: missing }); + reportError(error); + return jsonRpcErrorResponse( + httpStatusForErrorCode(error.code, 'ladder'), + error.code, + error.message, + error.data, + (message as JSONRPCRequest).id + ); + } + } + } + const product = await factory({ era: 'modern', ...(authInfo !== undefined && { authInfo }), @@ -432,14 +463,11 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa setNegotiatedProtocolVersion(server, claimedRevision); installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS); - if (route.messageKind === 'request') { - const meta = requestMetaOf((message as JSONRPCRequest).params); - if (meta !== undefined) { - seedClientIdentityFromEnvelope(server, { - clientInfo: meta[CLIENT_INFO_META_KEY] as Implementation | undefined, - clientCapabilities: meta[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined - }); - } + if (meta !== undefined) { + seedClientIdentityFromEnvelope(server, { + clientInfo: meta[CLIENT_INFO_META_KEY] as Implementation | undefined, + clientCapabilities: declaredClientCapabilities + }); } if (responseMode === 'json' && !warnedJsonModeSubscriptions && hasConfiguredSubscriptions(product)) { diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 5e9115391d..6fcdd9a327 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1,5 +1,6 @@ import type { BaseMetadata, + CacheHint, CallToolResult, CompleteRequestPrompt, CompleteRequestResourceTemplate, @@ -27,6 +28,8 @@ import type { import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, + assertValidCacheHint, + attachCacheHintFallback, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, @@ -413,14 +416,17 @@ export class McpServer { if (!resource.enabled) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} disabled`); } - return resource.readCallback(uri, ctx); + // A per-resource cache hint is the most specific configured + // author for this result's 2026-07-28 cache fields; it rides a + // never-serialized carrier and is resolved at the encode seam. + return attachCacheHintFallback(await resource.readCallback(uri, ctx), resource.cacheHint); } // Then check templates for (const template of Object.values(this._registeredResourceTemplates)) { const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); if (variables) { - return template.readCallback(uri, variables, ctx); + return attachCacheHintFallback(await template.readCallback(uri, variables, ctx), template.cacheHint); } } @@ -499,19 +505,36 @@ export class McpServer { * ); * ``` */ - registerResource(name: string, uriOrTemplate: string, config: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; + registerResource( + name: string, + uriOrTemplate: string, + config: ResourceMetadata & { cacheHint?: CacheHint }, + readCallback: ReadResourceCallback + ): RegisteredResource; registerResource( name: string, uriOrTemplate: ResourceTemplate, - config: ResourceMetadata, + config: ResourceMetadata & { cacheHint?: CacheHint }, readCallback: ReadResourceTemplateCallback ): RegisteredResourceTemplate; registerResource( name: string, uriOrTemplate: string | ResourceTemplate, - config: ResourceMetadata, + config: ResourceMetadata & { cacheHint?: CacheHint }, readCallback: ReadResourceCallback | ReadResourceTemplateCallback ): RegisteredResource | RegisteredResourceTemplate { + // The cache hint configures the encode-time cache fields of this + // resource's `resources/read` results (2026-07-28); it is not resource + // metadata and never appears on `resources/list` entries. + const cacheHint = config.cacheHint; + let metadata: ResourceMetadata = config; + if (cacheHint !== undefined) { + assertValidCacheHint(cacheHint, `resource ${name}`); + const rest = { ...config }; + delete rest.cacheHint; + metadata = rest; + } + if (typeof uriOrTemplate === 'string') { if (this._registeredResources[uriOrTemplate]) { throw new Error(`Resource ${uriOrTemplate} is already registered`); @@ -521,9 +544,12 @@ export class McpServer { name, (config as BaseMetadata).title, uriOrTemplate, - config, + metadata, readCallback as ReadResourceCallback ); + if (cacheHint !== undefined) { + registeredResource.cacheHint = cacheHint; + } this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -537,9 +563,12 @@ export class McpServer { name, (config as BaseMetadata).title, uriOrTemplate, - config, + metadata, readCallback as ReadResourceTemplateCallback ); + if (cacheHint !== undefined) { + registeredResourceTemplate.cacheHint = cacheHint; + } this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -1156,6 +1185,8 @@ export type RegisteredResource = { name: string; title?: string; metadata?: ResourceMetadata; + /** Cache hint applied to this resource's `resources/read` results on the 2026-07-28 revision. */ + cacheHint?: CacheHint; readCallback: ReadResourceCallback; enabled: boolean; enable(): void; @@ -1184,6 +1215,8 @@ export type RegisteredResourceTemplate = { resourceTemplate: ResourceTemplate; title?: string; metadata?: ResourceMetadata; + /** Cache hint applied to this template's `resources/read` results on the 2026-07-28 revision. */ + cacheHint?: CacheHint; readCallback: ReadResourceTemplateCallback; enabled: boolean; enable(): void; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 78daf3d5a8..20e2995923 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,5 +1,7 @@ import type { BaseContext, + CacheableResultMethod, + CacheHint, ClientCapabilities, CreateMessageRequest, CreateMessageRequestParamsBase, @@ -37,6 +39,8 @@ import type { ToolUseContent } from '@modelcontextprotocol/core'; import { + assertValidCacheHint, + attachCacheHintFallback, classifyInboundMessage, codecForVersion, CreateMessageResultSchema, @@ -136,6 +140,26 @@ export type ServerOptions = ProtocolOptions & { * @default 'legacy' */ eraSupport?: 'legacy' | 'dual-era' | 'modern'; + + /** + * Cache hints for the cacheable results of the 2026-07-28 protocol + * revision (`ttlMs` / `cacheScope`), keyed by operation. The cacheable + * operations are `tools/list`, `prompts/list`, `resources/list`, + * `resources/templates/list`, `resources/read` and `server/discover`. The + * hint is used when the result for that operation does not provide its own + * cache fields — most useful for the list results and `server/discover`, + * which the SDK builds itself. A hint registered with an individual + * resource (`registerResource(..., { cacheHint })`) takes precedence for + * that resource's `resources/read` results, field by field: a field the + * per-resource hint leaves unset still falls back to the per-operation + * hint configured here. + * + * Absent hints (or omitting this option entirely) keep today's behavior: + * cacheable 2026-07-28 results are emitted with `ttlMs: 0` and + * `cacheScope: 'private'`. Responses to 2025-era requests are never + * affected. Invalid values throw a `RangeError` at construction time. + */ + cacheHints?: Partial>; }; /** @@ -258,6 +282,7 @@ export class Server extends Protocol { * here only for the initialize-scoped accessor. */ private _dualEraInitializeVersion?: string; + private _cacheHints?: ServerOptions['cacheHints']; /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -277,6 +302,17 @@ export class Server extends Protocol { this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._eraSupport = options?.eraSupport ?? 'legacy'; + // Configured cache hints fail loudly at construction time (before any + // handler registration consults them). + if (options?.cacheHints !== undefined) { + for (const [operation, hint] of Object.entries(options.cacheHints)) { + if (hint !== undefined) { + assertValidCacheHint(hint, `cacheHints['${operation}']`); + } + } + this._cacheHints = options.cacheHints; + } + this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); @@ -487,14 +523,21 @@ export class Server extends Protocol { /** * Enforces server-side validation for `tools/call` results regardless of how the - * handler was registered. + * handler was registered, and attaches the configured per-operation cache hint + * (when one exists) so the 2026-07-28 encode seam can fill `ttlMs`/`cacheScope` + * for results that do not provide their own. The hint rides a symbol-keyed + * property that is never serialized, so 2025-era responses are unaffected. */ protected override _wrapHandler( method: string, handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise ): (request: JSONRPCRequest, ctx: ServerContext) => Promise { if (method !== 'tools/call') { - return handler; + const cacheHint = (this._cacheHints as Record | undefined)?.[method]; + if (cacheHint === undefined) { + return handler; + } + return async (request, ctx) => attachCacheHintFallback(await handler(request, ctx), cacheHint); } return async (request, ctx) => { // Era-exact validation: the request and result schemas come from diff --git a/packages/server/test/server/cacheHints.test.ts b/packages/server/test/server/cacheHints.test.ts new file mode 100644 index 0000000000..d865062bf6 --- /dev/null +++ b/packages/server/test/server/cacheHints.test.ts @@ -0,0 +1,272 @@ +/** + * The cache-hint surface for cacheable 2026-07-28 results: + * + * - `ServerOptions.cacheHints` (per-operation hints for SDK-built results), + * - `registerResource(..., { cacheHint })` (per-resource hints), + * - configuration-time validation (`RangeError`), + * - precedence, resolved per field: handler-returned values (when valid) + * over the per-resource hint over the per-operation hint over the defaults + * `{ ttlMs: 0, cacheScope: 'private' }`, + * - and the era boundary: 2025-era responses never gain any of it. + */ +import type { JSONRPCMessage, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { invoke } from '../../src/server/invoke.js'; +import { McpServer, ResourceTemplate } from '../../src/server/mcp.js'; +import type { ServerOptions } from '../../src/server/server.js'; +import { installModernOnlyHandlers, Server } from '../../src/server/server.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'cache-hint-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const modernRequest = (method: string, params: Record = {}): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: ENVELOPE } + }) as JSONRPCRequest; + +function buildMcpServer(options?: ServerOptions): McpServer { + const mcpServer = new McpServer({ name: 'cache-hint-server', version: '1.0.0' }, options); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return mcpServer; +} + +async function modernResult(mcpServer: McpServer, request: JSONRPCRequest): Promise> { + setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION); + const response = await invoke(mcpServer, request, { classification: MODERN }); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: Record }; + return body.result; +} + +describe('configuration-time validation', () => { + it('rejects a negative ttlMs in ServerOptions.cacheHints with a RangeError', () => { + expect(() => new McpServer({ name: 's', version: '1' }, { cacheHints: { 'tools/list': { ttlMs: -1 } } })).toThrowError(RangeError); + }); + + it('rejects a non-integer ttlMs and an unknown cacheScope with a RangeError', () => { + expect(() => new Server({ name: 's', version: '1' }, { cacheHints: { 'resources/read': { ttlMs: 1.5 } } })).toThrowError( + RangeError + ); + expect( + () => new Server({ name: 's', version: '1' }, { cacheHints: { 'server/discover': { cacheScope: 'shared' as never } } }) + ).toThrowError(RangeError); + }); + + it('rejects an invalid registerResource cacheHint with a RangeError', () => { + const mcpServer = buildMcpServer(); + expect(() => + mcpServer.registerResource('bad', 'test://bad', { cacheHint: { ttlMs: -5 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'x' }] + })) + ).toThrowError(RangeError); + }); +}); + +describe('modern (2026-07-28) responses', () => { + it('fills the defaults when nothing is configured', async () => { + const result = await modernResult(buildMcpServer(), modernRequest('tools/list')); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' }); + }); + + it('uses the per-operation hint from ServerOptions.cacheHints for SDK-built list results', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } } }); + const result = await modernResult(mcpServer, modernRequest('tools/list')); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' }); + }); + + it('uses the per-operation hint for server/discover', async () => { + const server = new Server({ name: 'discover-server', version: '1.0.0' }, { cacheHints: { 'server/discover': { ttlMs: 30_000 } } }); + installModernOnlyHandlers(server, [MODERN_REVISION]); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const response = await invoke(server, modernRequest('server/discover'), { classification: MODERN }); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: Record }; + expect(body.result).toMatchObject({ resultType: 'complete', ttlMs: 30_000, cacheScope: 'private' }); + expect(Array.isArray(body.result['supportedVersions'])).toBe(true); + }); + + it('uses the per-operation hint for prompts/list', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'prompts/list': { ttlMs: 15_000, cacheScope: 'public' } } }); + mcpServer.registerPrompt('greeting', { description: 'Say hello' }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }] + })); + const result = await modernResult(mcpServer, modernRequest('prompts/list')); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 15_000, cacheScope: 'public' }); + }); + + it('uses the per-operation hint for resources/list', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/list': { ttlMs: 20_000 } } }); + mcpServer.registerResource('plain', 'test://plain', {}, async uri => ({ + contents: [{ uri: uri.href, text: 'plain' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/list')); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 20_000, cacheScope: 'private' }); + }); + + it('uses the per-operation hint for resources/templates/list', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/templates/list': { ttlMs: 45_000, cacheScope: 'public' } } }); + mcpServer.registerResource( + 'templated', + new ResourceTemplate('test://things/{id}', { list: undefined }), + {}, + async (uri, { id }) => ({ contents: [{ uri: uri.href, text: `id=${String(id)}` }] }) + ); + const result = await modernResult(mcpServer, modernRequest('resources/templates/list')); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 45_000, cacheScope: 'public' }); + }); + + it('a per-resource cacheHint wins over the per-operation hint for that resource', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); + mcpServer.registerResource('hinted', 'test://hinted', { cacheHint: { ttlMs: 2_000, cacheScope: 'public' } }, async uri => ({ + contents: [{ uri: uri.href, text: 'hinted' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://hinted' })); + expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'public' }); + }); + + it('the per-operation hint applies to resources registered without their own hint', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); + mcpServer.registerResource('plain', 'test://plain', {}, async uri => ({ + contents: [{ uri: uri.href, text: 'plain' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://plain' })); + expect(result).toMatchObject({ ttlMs: 1_000, cacheScope: 'private' }); + }); + + it('a per-resource hint setting only cacheScope still takes ttlMs from the per-operation hint (per-field resolution)', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); + mcpServer.registerResource('scoped', 'test://scoped', { cacheHint: { cacheScope: 'public' } }, async uri => ({ + contents: [{ uri: uri.href, text: 'scoped' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://scoped' })); + expect(result).toMatchObject({ ttlMs: 1_000, cacheScope: 'public' }); + }); + + it('a per-resource hint setting only ttlMs still takes cacheScope from the per-operation hint (per-field resolution)', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { cacheScope: 'public' } } }); + mcpServer.registerResource('timed', 'test://timed', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'timed' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://timed' })); + expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'public' }); + }); + + it('when both configured hints set the same fields, the per-resource values win for every field', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000, cacheScope: 'private' } } }); + mcpServer.registerResource('full', 'test://full', { cacheHint: { ttlMs: 2_000, cacheScope: 'public' } }, async uri => ({ + contents: [{ uri: uri.href, text: 'full' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://full' })); + expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'public' }); + }); + + it('a field neither configured author sets falls back to the default', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); + mcpServer.registerResource('partial', 'test://partial', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'partial' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://partial' })); + expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'private' }); + }); + + it('fills the defaults for resources/read when neither configured author provides a hint', async () => { + const mcpServer = buildMcpServer(); + mcpServer.registerResource('bare', 'test://bare', {}, async uri => ({ + contents: [{ uri: uri.href, text: 'bare' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://bare' })); + expect(result).toMatchObject({ ttlMs: 0, cacheScope: 'private' }); + }); + + it('valid handler-returned cache fields win over every configured hint', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); + mcpServer.registerResource('authored', 'test://authored', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'authored' }], + ttlMs: 3_000, + cacheScope: 'public' + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://authored' })); + expect(result).toMatchObject({ ttlMs: 3_000, cacheScope: 'public' }); + }); + + it('invalid handler-returned values fall back to the configured hint', async () => { + const mcpServer = buildMcpServer(); + mcpServer.registerResource('invalid', 'test://invalid', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'invalid' }], + ttlMs: -10 + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://invalid' })); + expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'private' }); + }); + + it('never leaks the cacheHint configuration into resources/list entries', async () => { + const mcpServer = buildMcpServer(); + mcpServer.registerResource('hinted', 'test://hinted', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'hinted' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/list')); + const resources = result['resources'] as Array>; + expect(resources).toHaveLength(1); + expect('cacheHint' in resources[0]!).toBe(false); + }); +}); + +describe('the 2025 era is never affected', () => { + async function legacyExchange(mcpServer: McpServer, requests: JSONRPCMessage[]): Promise { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + await mcpServer.server.connect(serverTx); + for (const request of requests) { + serverTx.onmessage?.(request); + } + await new Promise(resolve => setTimeout(resolve, 10)); + await mcpServer.close(); + return sent; + } + + it('configured cache hints never reach a 2025-era response (no resultType, ttlMs or cacheScope on the wire)', async () => { + const mcpServer = buildMcpServer({ + cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' }, 'resources/read': { ttlMs: 1_000 } } + }); + mcpServer.registerResource('hinted', 'test://hinted', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'hinted' }] + })); + + const sent = await legacyExchange(mcpServer, [ + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage, + { jsonrpc: '2.0', id: 2, method: 'resources/read', params: { uri: 'test://hinted' } } as JSONRPCMessage, + { jsonrpc: '2.0', id: 3, method: 'resources/list', params: {} } as JSONRPCMessage + ]); + + expect(sent).toHaveLength(3); + for (const message of sent) { + const json = JSON.stringify(message); + expect(json).not.toContain('"resultType"'); + expect(json).not.toContain('"ttlMs"'); + expect(json).not.toContain('"cacheScope"'); + expect(json).not.toContain('"cacheHint"'); + } + }); +}); diff --git a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts new file mode 100644 index 0000000000..3decb71b60 --- /dev/null +++ b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts @@ -0,0 +1,98 @@ +/** + * The pre-dispatch client-capability gate at the HTTP entry: a request to a + * method that requires a client capability the request's envelope did not + * declare is refused with the typed `-32003` error and HTTP 400, before any + * server instance is constructed or dispatched. + * + * No request method served on the 2026-07-28 registry has a static + * requirement today, so these tests drive the gate by adding (and removing) a + * temporary entry to the requirement table; the production behavior with the + * empty table — every modern request passes the gate — is pinned too. + */ +import type { ClientCapabilities } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + REQUIRED_CLIENT_CAPABILITIES_BY_METHOD +} from '@modelcontextprotocol/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +const MODERN_REVISION = '2026-07-28'; + +const envelope = (clientCapabilities: ClientCapabilities) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'gate-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: clientCapabilities +}); + +function postEcho(clientCapabilities: ClientCapabilities): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'hi' }, _meta: envelope(clientCapabilities) } + }) + }); +} + +function factory(): McpServer { + const mcpServer = new McpServer({ name: 'gate-test-server', version: '1.0.0' }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return mcpServer; +} + +const requirementTable = REQUIRED_CLIENT_CAPABILITIES_BY_METHOD as Record; + +afterEach(() => { + delete requirementTable['tools/call']; +}); + +describe('the pre-dispatch client-capability gate', () => { + it('serves modern requests normally while no requirement applies (the table is empty in production)', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postEcho({})); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hi'); + }); + + it('refuses a request missing a required capability with -32003 and HTTP 400, echoing the request id', async () => { + requirementTable['tools/call'] = { sampling: {} }; + let factoryRan = false; + const handler = createMcpHandler(() => { + factoryRan = true; + return factory(); + }); + + const response = await handler.fetch(postEcho({ elicitation: {} })); + expect(response.status).toBe(400); + const body = (await response.json()) as { + id: unknown; + error: { code: number; data?: { requiredCapabilities?: ClientCapabilities } }; + }; + expect(body.error.code).toBe(-32_003); + expect(body.error.data?.requiredCapabilities).toEqual({ sampling: {} }); + expect(body.id).toBe(7); + // Pre-dispatch: the refusal happens before any per-request instance exists. + expect(factoryRan).toBe(false); + }); + + it('serves the request once the required capability is declared in the envelope', async () => { + requirementTable['tools/call'] = { sampling: {} }; + const handler = createMcpHandler(factory); + const response = await handler.fetch(postEcho({ sampling: {} })); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hi'); + }); +}); From 3e97603469bbbf6c3fb28a4a588cc75e08a00a5e Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:14:48 +0100 Subject: [PATCH 17/37] fix(core): pin the modern-path rejection codes for header mismatches and missing envelope fields (#2311) --- .changeset/pin-modern-rejection-codes.md | 6 + .../core/src/shared/inboundClassification.ts | 102 +++++++++----- .../test/shared/errorHttpStatusMatrix.test.ts | 27 ++-- .../test/shared/inboundClassification.test.ts | 64 ++++++--- .../shared/inboundLadderCellSheet.test.ts | 131 +++++++----------- .../test/server/createMcpHandler.test.ts | 27 +++- .../server/test/server/eraSupport.test.ts | 7 +- test/conformance/expected-failures.yaml | 17 +-- 8 files changed, 209 insertions(+), 172 deletions(-) create mode 100644 .changeset/pin-modern-rejection-codes.md diff --git a/.changeset/pin-modern-rejection-codes.md b/.changeset/pin-modern-rejection-codes.md new file mode 100644 index 0000000000..f54bdd9785 --- /dev/null +++ b/.changeset/pin-modern-rejection-codes.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +--- + +Pin the modern (2026-07-28) HTTP serving path's rejection codes to the assignments the published conformance suite asserts: a header/body cross-check mismatch (`MCP-Protocol-Version` or `Mcp-Method` disagreeing with the request body) is now rejected with `-32001` (HeaderMismatch), and a request whose protocol-version header names a modern revision but whose body is missing the `_meta` envelope (or its required protocol-version key) is rejected with `-32602` invalid params naming the missing key(s). Both keep HTTP 400. These cells previously emitted a provisional `-32004` while the upstream error-code discussion was open. The envelope-less rejection on a modern-only endpoint (`-32004` with the supported-versions list), the 2025-era serving paths, and the client-side probe handling are unchanged. diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 14d7b16f54..3dd85ad2ad 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -41,12 +41,21 @@ * legacy and hand-wired traffic is never classified, which keeps its * dispatch behavior byte-identical to today's. * - * Some ladder cells do not have a settled error code upstream yet (the - * header/body mismatch family: the candidate codes are `-32001`, `-32602` - * and `-32004`; see the note in `test/conformance/expected-failures.yaml`). - * Those outcomes are emitted with a single provisional code and are marked - * `settled: false` so tests and consumers can treat them as parameterized - * rather than pinned. + * Error codes for the modern-path rejection cells follow the published + * conformance suite (and the spec text it asserts): + * + * - A header/body cross-check mismatch (the `MCP-Protocol-Version` header + * disagreeing with the body, or the `Mcp-Method` header disagreeing with the + * body method) is rejected with `-32001` (`HeaderMismatch`) on HTTP 400. + * - A request whose protocol-version header names a modern revision but whose + * body carries no `_meta` envelope claim — including an envelope present but + * missing the required protocol-version key — is rejected with `-32602` + * (invalid params) naming the missing key(s), on HTTP 400. + * + * Should a future spec revision or conformance release change these + * assignments, the affected cells are re-derived against that release; the + * `settled` flag on {@linkcode InboundLadderRejection} stays available to mark + * a cell provisional again while such a change is in flight. */ import { PROTOCOL_VERSION_META_KEY } from '../types/constants.js'; import { ProtocolErrorCode } from '../types/enums.js'; @@ -158,6 +167,23 @@ export interface InboundLadderRejection { /** The outcome of classifying one inbound HTTP request. */ export type InboundClassificationOutcome = InboundLegacyRoute | InboundModernRoute | InboundLadderRejection; +/* ------------------------------------------------------------------------ * + * Header cross-check mismatches + * ------------------------------------------------------------------------ */ + +/** + * The error code emitted for header/body cross-check mismatches: the + * `MCP-Protocol-Version` header disagreeing with the body's envelope claim (or + * with the body's classification), and the `Mcp-Method` header disagreeing + * with the body method. + * + * `-32001` is the SEP-2243 `HeaderMismatch` code, as asserted by the published + * conformance suite for header-validation failures. It has no + * {@linkcode ProtocolErrorCode} member because it is not part of the 2025-era + * wire vocabulary; the validation ladder is its only emitter. + */ +export const HEADER_MISMATCH_ERROR_CODE = -32_001; + /* ------------------------------------------------------------------------ * * The validation ladder as data * ------------------------------------------------------------------------ */ @@ -219,11 +245,12 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor rung: 'era-classification', order: 3, evaluatedAt: 'edge', - codes: [ProtocolErrorCode.UnsupportedProtocolVersion], + codes: [HEADER_MISMATCH_ERROR_CODE, ProtocolErrorCode.UnsupportedProtocolVersion], conformance: ['server-stateless', 'http-header-validation', 'http-custom-header-server-validation'], rationale: - 'Body-primary era classification with the protocol-version header as a cross-check; a header/body disagreement is a ' + - 'distinct outcome whose exact error code is still under discussion upstream (provisional, see expected-failures.yaml).' + 'Body-primary era classification with the protocol-version header as a cross-check; a header/body disagreement is rejected ' + + 'with -32001 (HeaderMismatch), and an envelope-less request on a modern-only endpoint is answered with the ' + + 'unsupported-protocol-version error naming the supported revisions.' }, { rung: 'envelope', @@ -232,8 +259,9 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor codes: [ProtocolErrorCode.InvalidParams], conformance: ['server-stateless'], rationale: - 'A present envelope claim with a malformed envelope is an invalid-params rejection naming the offending key — never a ' + - 'silent fall back to legacy handling. This is the only place an invalid-params rejection maps to HTTP 400.' + 'A present envelope claim with a malformed envelope — and a missing envelope on a request whose protocol-version header ' + + 'names a modern revision — is an invalid-params rejection naming the offending or missing key(s); never a silent fall ' + + 'back to legacy handling. This is the only place an invalid-params rejection maps to HTTP 400.' }, { rung: 'method-registry', @@ -293,7 +321,7 @@ export const LADDER_ERROR_HTTP_STATUS: Readonly> = { [ProtocolErrorCode.MethodNotFound]: 404, [ProtocolErrorCode.UnsupportedProtocolVersion]: 400, [ProtocolErrorCode.MissingRequiredClientCapability]: 400, - [-32_001]: 400 + [HEADER_MISMATCH_ERROR_CODE]: 400 }; /** @@ -307,23 +335,6 @@ export function httpStatusForErrorCode(code: number, origin: 'ladder' | 'in-band return LADDER_ERROR_HTTP_STATUS[code] ?? 400; } -/* ------------------------------------------------------------------------ * - * Provisional cells - * ------------------------------------------------------------------------ */ - -/** - * The error code emitted for header/body cross-check mismatches (the - * protocol-version header disagreeing with the body classification, and the - * `Mcp-Method` header disagreeing with the body method). - * - * The exact code for these cells is still under discussion upstream — the - * candidates are `-32001`, `-32602` and `-32004` (see the note in - * `test/conformance/expected-failures.yaml`). Until a published conformance - * release settles them, the ladder emits the protocol-layer era-mismatch code - * and marks the outcome `settled: false`. - */ -export const PROVISIONAL_CROSS_CHECK_MISMATCH_CODE: number = ProtocolErrorCode.UnsupportedProtocolVersion; - /* ------------------------------------------------------------------------ * * The classifier * ------------------------------------------------------------------------ */ @@ -352,10 +363,10 @@ function crossCheckMismatch(cell: string, header: string, body: string): Inbound 'era-classification', cell, 400, - new ProtocolError(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE, `Bad Request: the request headers and body disagree: ${body}`, { + new ProtocolError(HEADER_MISMATCH_ERROR_CODE, `Bad Request: the request headers and body disagree: ${body}`, { mismatch: { header, body } }), - false + true ); } @@ -504,13 +515,29 @@ function classifyRequestBody(request: InboundHttpRequest, body: Record issue.problem === 'missing') + .map(issue => issue.key); + const missing = meta === undefined ? ['_meta'] : missingFromEnvelope.length > 0 ? missingFromEnvelope : [PROTOCOL_VERSION_META_KEY]; + return rejection( + 'envelope', 'modern-header-without-claim', - headerVersion, - 'the MCP-Protocol-Version header names a modern protocol revision but the request body carries no _meta envelope claim' + 400, + new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid params: the MCP-Protocol-Version header names protocol revision ${headerVersion}, but the request is missing ` + + `the required per-request envelope key(s): ${missing.join(', ')}`, + { envelope: { missing } } + ), + true ); } return { kind: 'legacy', reason: 'no-claim', ...(headerVersion !== undefined && { requestedVersion: headerVersion }) }; @@ -687,8 +714,7 @@ export function classifyInboundMessage(message: { method: string; params?: unkno * versions and echoing the version the request named (when it named one — * `requested` is omitted rather than fabricated when the request named no * version at all), so a legacy client can discover what the endpoint serves - * from the error alone. (This cell shares its numeric code with the - * still-disputed mismatch cells above, but its own outcome is settled.) + * from the error alone. * - Posted responses and batch arrays are invalid requests on the modern era. * - Non-`POST` methods are not allowed. * - Legacy-classified notifications return `undefined`: the caller answers diff --git a/packages/core/test/shared/errorHttpStatusMatrix.test.ts b/packages/core/test/shared/errorHttpStatusMatrix.test.ts index 6cab1c46d0..7f505daece 100644 --- a/packages/core/test/shared/errorHttpStatusMatrix.test.ts +++ b/packages/core/test/shared/errorHttpStatusMatrix.test.ts @@ -13,9 +13,9 @@ * carries its own HTTP 400 and is the only invalid-params rejection that * maps to 400. * - * Cells whose error CODE is still disputed upstream (the header/body mismatch - * family) stay parameterized: the emitted code is asserted as candidate-set - * membership, never a pinned literal. + * The header/body mismatch family is pinned to `-32001` (HeaderMismatch) and + * the missing-envelope cells to `-32602`, the assignments asserted by the + * published conformance suite. * * Transport- and dispatch-level behavior for these cells is covered by the * ladder cell sheet and the per-request transport suites; this file pins the @@ -23,11 +23,7 @@ */ import { describe, expect, test } from 'vitest'; -import { - httpStatusForErrorCode, - LADDER_ERROR_HTTP_STATUS, - PROVISIONAL_CROSS_CHECK_MISMATCH_CODE -} from '../../src/shared/inboundClassification.js'; +import { HEADER_MISMATCH_ERROR_CODE, httpStatusForErrorCode, LADDER_ERROR_HTTP_STATUS } from '../../src/shared/inboundClassification.js'; import { ProtocolErrorCode } from '../../src/types/enums.js'; describe('the status matrix — pinned cells', () => { @@ -84,15 +80,10 @@ describe('the status matrix — pinned cells', () => { }); }); -describe('the status matrix — parameterized (disputed) cells', () => { - test('the header/body mismatch family code is a candidate, not a pin, and maps to 400 whichever candidate it is', () => { - const candidates = [-32_001, ProtocolErrorCode.InvalidParams, ProtocolErrorCode.UnsupportedProtocolVersion]; - expect(candidates).toContain(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE); - // Whatever the upstream resolution turns out to be, a ladder-originated - // rejection in this family answers HTTP 400: every candidate either has - // a 400 row or is carried by the classifier's own httpStatus. - if (PROVISIONAL_CROSS_CHECK_MISMATCH_CODE !== ProtocolErrorCode.InvalidParams) { - expect(httpStatusForErrorCode(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE, 'ladder')).toBe(400); - } +describe('the status matrix — header/body mismatch family', () => { + test('the header/body mismatch family is pinned to -32001 (HeaderMismatch) and maps to HTTP 400', () => { + expect(HEADER_MISMATCH_ERROR_CODE).toBe(-32_001); + expect(LADDER_ERROR_HTTP_STATUS[HEADER_MISMATCH_ERROR_CODE]).toBe(400); + expect(httpStatusForErrorCode(HEADER_MISMATCH_ERROR_CODE, 'ladder')).toBe(400); }); }); diff --git a/packages/core/test/shared/inboundClassification.test.ts b/packages/core/test/shared/inboundClassification.test.ts index 30d21c94e5..d288fd21d8 100644 --- a/packages/core/test/shared/inboundClassification.test.ts +++ b/packages/core/test/shared/inboundClassification.test.ts @@ -5,25 +5,19 @@ * cross-checks, notification routing, element-wise batch classification, and * the modern-only (strict) rejection mapping. * - * Cells whose exact error code is still under discussion upstream (the - * header/body mismatch family) are asserted as parameterized: the outcome is - * pinned (a rejection, marked unsettled), the code is asserted to be the - * provisional constant and a member of the candidate set — never a hard-coded - * literal of its own. + * The header/body mismatch cells are pinned to `-32001` (HeaderMismatch) and + * the missing-envelope / missing-protocol-version cells to `-32602` (invalid + * params naming the missing key(s)) — the assignments asserted by the + * published conformance suite. */ import { describe, expect, test } from 'vitest'; import { hasEnvelopeClaim, validateEnvelopeMeta } from '../../src/shared/envelope.js'; import type { InboundHttpRequest, InboundLegacyRoute } from '../../src/shared/inboundClassification.js'; -import { - classifyInboundRequest, - modernOnlyStrictRejection, - PROVISIONAL_CROSS_CHECK_MISMATCH_CODE -} from '../../src/shared/inboundClassification.js'; +import { classifyInboundRequest, modernOnlyStrictRejection } from '../../src/shared/inboundClassification.js'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; const MODERN_REVISION = '2026-07-28'; -const MISMATCH_CODE_CANDIDATES = [-32_001, -32_602, -32_004]; const ENVELOPE = { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, @@ -66,12 +60,10 @@ const expectMismatch = (outcome: ReturnType, cell expect(outcome.cell).toBe(cell); expect(outcome.rung).toBe('era-classification'); expect(outcome.httpStatus).toBe(400); - // Parameterized: the exact code for the mismatch family is not settled - // upstream. The classifier emits the provisional constant; assert set - // membership rather than a literal of our own. - expect(outcome.settled).toBe(false); - expect(outcome.code).toBe(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE); - expect(MISMATCH_CODE_CANDIDATES).toContain(outcome.code); + // Pinned: a header/body disagreement is a header-validation failure and + // answers -32001 (HeaderMismatch), per the published conformance suite. + expect(outcome.settled).toBe(true); + expect(outcome.code).toBe(-32_001); }; describe('envelope claim detection (claim = the reserved protocol-version key)', () => { @@ -219,15 +211,47 @@ describe('body-primary era predicate', () => { }); }); -describe('header cross-checks (parameterized mismatch family)', () => { +describe('header cross-checks (-32001 HeaderMismatch) and the missing-envelope rejection (-32602)', () => { test('a body claim disagreeing with the protocol-version header is a mismatch outcome', () => { const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: '2025-06-18' })); expectMismatch(outcome, 'header-body-version-mismatch'); }); - test('a modern header on a claim-less body is a mismatch outcome, not an upgrade', () => { + test('a modern header on a claim-less body is rejected with invalid params naming the missing _meta envelope', () => { + // Never an upgrade and never a silent legacy fallthrough: the modern + // revisions require the per-request envelope, so the request is + // answered as missing required params. const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: MODERN_REVISION })); - expectMismatch(outcome, 'modern-header-without-claim'); + expect(outcome).toMatchObject({ + kind: 'reject', + rung: 'envelope', + cell: 'modern-header-without-claim', + httpStatus: 400, + code: -32_602, + settled: true, + data: { envelope: { missing: ['_meta'] } } + }); + }); + + test('a modern header on a body whose _meta lacks the protocol-version key names that key as missing', () => { + const body = { + jsonrpc: '2.0', + id: 4, + method: 'tools/list', + params: { _meta: { [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, [CLIENT_CAPABILITIES_META_KEY]: {} } } + }; + const outcome = classifyInboundRequest(post(body, { protocolVersion: MODERN_REVISION })); + expect(outcome).toMatchObject({ + kind: 'reject', + rung: 'envelope', + cell: 'modern-header-without-claim', + httpStatus: 400, + code: -32_602, + settled: true, + data: { envelope: { missing: [PROTOCOL_VERSION_META_KEY] } } + }); + if (outcome.kind !== 'reject') return; + expect(outcome.message).toContain(PROTOCOL_VERSION_META_KEY); }); test('initialize with a modern protocol-version header is a mismatch outcome', () => { diff --git a/packages/core/test/shared/inboundLadderCellSheet.test.ts b/packages/core/test/shared/inboundLadderCellSheet.test.ts index 9eedf58f52..6713e3bd4b 100644 --- a/packages/core/test/shared/inboundLadderCellSheet.test.ts +++ b/packages/core/test/shared/inboundLadderCellSheet.test.ts @@ -1,14 +1,16 @@ /** * The inbound validation-ladder cell sheet. * - * Each row names one ladder cell, whether its outcome is pinned or - * parameterized, the conformance scenarios that exercise it (where one - * exists), and the expected outcome. Pinned rows assert exact codes and HTTP - * statuses; parameterized rows assert the outcome class and that the emitted - * code is the documented provisional value drawn from the candidate set — - * those cells are re-derived when a published conformance release settles the - * disputed assignments (see the note in - * `test/conformance/expected-failures.yaml`). + * Each row names one ladder cell, the conformance scenarios that exercise it + * (where one exists), and the expected outcome with its exact code and HTTP + * status. The header/body mismatch and missing-envelope cells were originally + * parameterized (asserted as candidate-set membership) while their error codes + * were under discussion upstream; they are now pinned to the assignments the + * published conformance suite asserts (`-32001` HeaderMismatch for header/body + * disagreements, `-32602` invalid params naming the missing key(s) for a + * missing envelope or missing protocol-version key). If a future published + * conformance release changes an assignment, the affected rows are re-derived + * here. * * Cells evaluated at protocol dispatch (the era registry gate, per-method * params, capability assertion) are listed for ordering and status mapping @@ -23,13 +25,11 @@ import { httpStatusForErrorCode, INBOUND_VALIDATION_LADDER, LADDER_ERROR_HTTP_STATUS, - modernOnlyStrictRejection, - PROVISIONAL_CROSS_CHECK_MISMATCH_CODE + modernOnlyStrictRejection } from '../../src/shared/inboundClassification.js'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; const MODERN_REVISION = '2026-07-28'; -const MISMATCH_CODE_CANDIDATES = [-32_001, -32_602, -32_004]; const ENVELOPE = { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, @@ -54,8 +54,6 @@ const post = (body: unknown, headers: { protocolVersion?: string; mcpMethod?: st interface SheetRow { /** Stable cell identifier (matches `InboundLadderRejection.cell` for rejection cells). */ cell: string; - /** Pinned cells assert exact outcomes; parameterized cells assert the provisional outcome + candidate-set membership. */ - status: 'pinned' | 'parameterized'; /** Conformance scenarios exercising the cell, where one exists in the published referee. */ conformance: readonly string[]; /** The classifier input. */ @@ -64,7 +62,7 @@ interface SheetRow { strict?: boolean; /** The expected outcome for routing cells. */ route?: 'legacy' | 'modern'; - /** The expected rejection (exact for pinned cells; for parameterized cells `code` is the provisional value). */ + /** The expected rejection, asserted exactly. */ reject?: Partial; /** Why the cell behaves the way it does. */ rationale: string; @@ -74,7 +72,6 @@ const SHEET: readonly SheetRow[] = [ /* --- Routing cells (pinned) --------------------------------------------------- */ { cell: 'modern-enveloped-request', - status: 'pinned', conformance: ['server-stateless'], input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: MODERN_REVISION }), route: 'modern', @@ -82,7 +79,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-enveloped-request-header-stripped', - status: 'pinned', conformance: ['server-stateless'], input: post(enveloped('tools/call', { name: 'echo', arguments: {} })), route: 'modern', @@ -90,7 +86,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-claimless-request', - status: 'pinned', conformance: [], input: post(bare('tools/list'), { protocolVersion: '2025-06-18' }), route: 'legacy', @@ -98,7 +93,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-initialize', - status: 'pinned', conformance: [], input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), route: 'legacy', @@ -106,7 +100,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-enveloped-initialize', - status: 'pinned', conformance: ['server-stateless'], input: post(enveloped('initialize'), { protocolVersion: MODERN_REVISION, mcpMethod: 'initialize' }), route: 'modern', @@ -117,7 +110,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-method-routed-get', - status: 'pinned', conformance: [], input: { httpMethod: 'GET' }, route: 'legacy', @@ -125,7 +117,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-notification-stripped-header', - status: 'pinned', conformance: [], input: post({ jsonrpc: '2.0', method: 'notifications/initialized' }), route: 'legacy', @@ -134,7 +125,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-notification-by-header', - status: 'pinned', conformance: ['http-header-validation'], input: post({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, { protocolVersion: MODERN_REVISION }), route: 'modern', @@ -142,7 +132,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-batch', - status: 'pinned', conformance: [], input: post([bare('tools/list')]), route: 'legacy', @@ -150,7 +139,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-response-post', - status: 'pinned', conformance: [], input: post({ jsonrpc: '2.0', id: 5, result: {} }), route: 'legacy', @@ -160,7 +148,6 @@ const SHEET: readonly SheetRow[] = [ /* --- Edge rejection cells (pinned) -------------------------------------------- */ { cell: 'envelope-invalid', - status: 'pinned', conformance: ['server-stateless'], input: post({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }), reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, @@ -168,7 +155,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'batch-with-modern-element', - status: 'pinned', conformance: [], input: post([bare('tools/list'), enveloped('tools/call', { name: 'echo', arguments: {} })]), reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, @@ -176,7 +162,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'batch-with-invalid-element', - status: 'pinned', conformance: [], input: post([bare('tools/list'), { nonsense: true }]), reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, @@ -184,7 +169,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'invalid-json-rpc-body', - status: 'pinned', conformance: [], input: post({ hello: 'world' }), reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, @@ -195,7 +179,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'empty-batch', - status: 'pinned', conformance: [], input: post([]), reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, @@ -206,7 +189,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'notification-envelope-invalid', - status: 'pinned', conformance: [], input: post({ jsonrpc: '2.0', method: 'notifications/progress', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } } }), reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, @@ -218,7 +200,6 @@ const SHEET: readonly SheetRow[] = [ /* --- Modern-only (strict) cells (pinned) --------------------------------------- */ { cell: 'modern-only-missing-envelope', - status: 'pinned', conformance: ['server-stateless'], input: post(bare('tools/list')), strict: true, @@ -229,7 +210,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-only-missing-envelope-initialize', - status: 'pinned', conformance: ['server-stateless'], input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), strict: true, @@ -246,7 +226,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-only-method-not-allowed', - status: 'pinned', conformance: [], input: { httpMethod: 'DELETE' }, strict: true, @@ -255,7 +234,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-only-batch-not-supported', - status: 'pinned', conformance: [], input: post([bare('tools/list')]), strict: true, @@ -264,7 +242,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-only-response-post', - status: 'pinned', conformance: [], input: post({ jsonrpc: '2.0', id: 5, result: {} }), strict: true, @@ -272,88 +249,89 @@ const SHEET: readonly SheetRow[] = [ rationale: 'There is no server-to-client request channel on the modern era, so posted responses are invalid requests.' }, - /* --- Parameterized cells (disputed error-code assignments) --------------------- */ + /* --- Header cross-check and missing-envelope cells (pinned to the published suite) --- */ { cell: 'header-body-version-mismatch', - status: 'parameterized', - conformance: ['http-header-validation', 'http-custom-header-server-validation'], + conformance: ['server-stateless', 'http-header-validation', 'http-custom-header-server-validation'], input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: '2025-06-18' }), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, - rationale: 'Header/body protocol-version disagreement; the exact code is still under discussion upstream.' + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'Header/body protocol-version disagreement is a header-validation failure: -32001 (HeaderMismatch) on HTTP 400, as ' + + 'asserted by the published conformance suite.' }, { cell: 'modern-header-without-claim', - status: 'parameterized', - conformance: ['http-header-validation'], + conformance: ['server-stateless'], input: post(bare('tools/list'), { protocolVersion: MODERN_REVISION }), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, - rationale: 'A modern header on a claim-less body is a disagreement, not an upgrade; code pending upstream settlement.' + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, + rationale: + 'A modern protocol-version header on a claim-less body is a modern-classified request missing its required _meta ' + + 'envelope: invalid params naming the missing key(s), never an upgrade and never a silent legacy fallthrough.' }, { cell: 'initialize-with-modern-header', - status: 'parameterized', - conformance: ['http-header-validation'], + conformance: [], input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } }), { protocolVersion: MODERN_REVISION }), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, - rationale: 'An envelope-less initialize classifies legacy; a modern header on it is the same disagreement family.' + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'An envelope-less initialize classifies legacy; a modern header on it is a header/body disagreement and answers the ' + + 'same -32001 (HeaderMismatch) as the rest of the mismatch family.' }, { cell: 'method-header-mismatch', - status: 'parameterized', - conformance: ['http-custom-header-server-validation'], + conformance: ['http-header-validation', 'http-custom-header-server-validation'], input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: MODERN_REVISION, mcpMethod: 'tools/list' }), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, - rationale: 'The Mcp-Method header must describe the body it accompanies; the rejection code is pending upstream settlement.' + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'The Mcp-Method header must describe the body it accompanies; a disagreement is a header-validation failure and ' + + 'answers -32001 (HeaderMismatch) on HTTP 400.' }, { cell: 'notification-header-body-version-mismatch', - status: 'parameterized', conformance: [], input: post( { jsonrpc: '2.0', method: 'notifications/progress', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }, { protocolVersion: '2025-06-18' } ), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, rationale: - 'A notification body claim disagreeing with the protocol-version header is the same disagreement family as the request ' + - 'cells above; the exact code is still under discussion upstream.' + 'A notification body claim disagreeing with the protocol-version header is the same header-validation failure as the ' + + 'request cells above and answers the same -32001 (HeaderMismatch).' }, { cell: 'notification-method-header-mismatch', - status: 'parameterized', conformance: [], input: post( { jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: 1, progress: 1 } }, { protocolVersion: MODERN_REVISION, mcpMethod: 'notifications/cancelled' } ), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, rationale: 'The Mcp-Method header must describe the notification body it accompanies (validated only when the notification ' + - 'classifies modern); the rejection code is pending upstream settlement.' + 'classifies modern); a disagreement answers -32001 (HeaderMismatch).' }, { cell: 'multi-fault-mismatched-claim-and-malformed-envelope', - status: 'parameterized', conformance: ['server-stateless', 'http-header-validation'], // The claim names a different version than the header AND the envelope - // is missing required keys: today the envelope rung answers (the - // mismatch is only checked on a valid envelope), so the emitted code is - // -32602 — but the precedence between the era-classification and - // envelope rungs for multi-fault requests is part of the disputed set. + // is missing required keys: the envelope rung answers (the header + // cross-check is only evaluated on a valid envelope), so the emitted + // code is the envelope rung's -32602. input: post( { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }, { protocolVersion: '2025-06-18' } ), - reject: { httpStatus: 400 }, + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, rationale: - 'Multi-fault precedence between the version error and invalid params is not settled upstream; asserted as candidate-set membership only.' + 'Multi-fault precedence: envelope validity is checked before the header cross-check, so the malformed envelope answers ' + + 'with invalid params; the mismatch is never reached.' } ]; @@ -383,26 +361,15 @@ describe('inbound validation-ladder cell sheet', () => { expect(outcome.kind).toBe('reject'); if (outcome.kind !== 'reject') return; - if (row.status === 'pinned') { - expect(outcome).toMatchObject(row.reject ?? {}); - } else { - // Parameterized: outcome class and provisional code only — the - // exact assignment is re-derived from a future conformance pin. - if (row.reject?.rung !== undefined) expect(outcome.rung).toBe(row.reject.rung); - if (row.reject?.httpStatus !== undefined) expect(outcome.httpStatus).toBe(row.reject.httpStatus); - expect(MISMATCH_CODE_CANDIDATES).toContain(outcome.code); - if (row.reject?.settled !== undefined) { - expect(outcome.settled).toBe(row.reject.settled); - expect(outcome.code).toBe(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE); - } - } + expect(outcome).toMatchObject(row.reject ?? {}); }); - test('every cell id is unique and every parameterized cell is marked unsettled or candidate-bound', () => { + test('every cell id is unique and every rejection row pins an expected outcome', () => { const ids = SHEET.map(row => row.cell); expect(new Set(ids).size).toBe(ids.length); - for (const row of SHEET.filter(candidate => candidate.status === 'parameterized')) { - expect(row.reject).toBeDefined(); + for (const row of SHEET.filter(candidate => candidate.route === undefined)) { + expect(row.reject?.code).toBeDefined(); + expect(row.reject?.httpStatus).toBeDefined(); } }); }); diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 0170a12ead..a07df6f264 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -201,7 +201,7 @@ describe('createMcpHandler — modern path', () => { expect(state.contexts).toHaveLength(0); }); - it('keeps the disputed header/body mismatch cells inside the candidate code set (parameterized, not pinned)', async () => { + it('rejects a header/body protocol-version mismatch with -32001 (HeaderMismatch) over HTTP 400', async () => { const { factory } = testFactory(); const onerror = vi.fn(); const handler = createMcpHandler(factory, { onerror }); @@ -209,12 +209,33 @@ describe('createMcpHandler — modern path', () => { const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }), { 'mcp-protocol-version': '2025-11-25' })); expect(response.status).toBe(400); const body = (await response.json()) as JSONRPCErrorBody; - expect([-32_001, -32_602, -32_004]).toContain(body.error.code); - // Whatever the disputed code lands on, the rejection echoes the request id. + expect(body.error.code).toBe(-32_001); + // The rejection echoes the request id. expect(body.id).toBe(1); expect(onerror).toHaveBeenCalled(); }); + it('rejects a modern-classified request without a _meta envelope with -32602 naming the missing key over HTTP 400', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + // The MCP-Protocol-Version header names the modern revision but the body + // carries no per-request envelope: invalid params naming what is missing, + // not a version error and not silent legacy serving. + const response = await handler.fetch( + postRequest( + { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} }, + { 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'tools/list' } + ) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_602); + expect(JSON.stringify(body.error.data)).toContain('_meta'); + expect(body.id).toBe(11); + expect(state.contexts).toHaveLength(0); + }); + it('answers entry-internal failures with 500/-32603 and reports them through onerror', async () => { const onerror = vi.fn(); const handler = createMcpHandler( diff --git a/packages/server/test/server/eraSupport.test.ts b/packages/server/test/server/eraSupport.test.ts index 0b95b9e46f..78ff050cb7 100644 --- a/packages/server/test/server/eraSupport.test.ts +++ b/packages/server/test/server/eraSupport.test.ts @@ -161,9 +161,10 @@ describe("DV-31: strict 'modern' on a long-lived connection", () => { const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); expect(isJSONRPCErrorResponse(response)).toBe(true); if (isJSONRPCErrorResponse(response)) { - // Note: this cell shares its numeric code (−32004) with the - // still-disputed header/body mismatch family; the cell itself is - // settled (unsupported protocol version + supported list). + // The envelope-less request on a modern-only instance answers the + // unsupported-protocol-version error with the supported list (the + // HTTP entry's header/body mismatch cells use −32001 instead; there + // is no header layer on a long-lived connection). expect(response.error.code).toBe(-32_004); const data = response.error.data as { supported?: string[]; requested?: string }; // The strict instance serves only modern revisions, so the supported diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index abfb3751d3..486df89058 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -10,11 +10,11 @@ # are still not shipped in the published release — the runner reports them # unknown/failed; their entries below cover them either way. # -# NOTE: the draft error-code assignments exercised by the SEP-2243 server -# scenarios (-32001 HeaderMismatch) and their neighbours (-32602, -32004) are -# still under discussion upstream (pending conformance #336). Those cells are -# treated as parameterized, not settled: the entries below record today's -# referee behavior and are re-derived when a #336-containing referee is pinned. +# NOTE: the SDK's modern-path rejection codes are aligned with what this +# referee asserts: header/body mismatches answer -32001 (HeaderMismatch) and a +# missing _meta envelope (or missing protocolVersion key) answers -32602. +# If a future published conformance release changes those assignments, the +# affected cells are re-derived when that release is pinned. # # Entries are grouped by SEP. As each SEP/milestone is implemented in the SDK the # corresponding scenarios start passing and MUST be removed from this list (the @@ -78,9 +78,10 @@ server: # SEP-2549 (caching): no ttlMs/cacheScope support; scenario also hits the # stateful-mode "Session ID required" error. - caching - # SEP-2243 (HTTP header standardization): -32001 HeaderMismatch handling and - # case-insensitive/whitespace-trimmed header validation not implemented. - # (Error-code cells parameterized pending conformance #336 — see header note.) + # SEP-2243 (HTTP header standardization): the reject cells the SDK does + # answer now use -32001 (HeaderMismatch), but missing-header enforcement + # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, + # so those reject cells are still accepted with 200. - http-header-validation - http-custom-header-server-validation # WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level From cfd7dbfc3f8b42ef942d566f0b5b5d23e9bb9335 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:52:14 +0100 Subject: [PATCH 18/37] =?UTF-8?q?test(e2e):=20dual-era=20serving=20coverag?= =?UTF-8?q?e=20=E2=80=94=20entry,=20stdio,=20sessionful=20BYO,=20streaming?= =?UTF-8?q?,=20and=20stamping=20scenarios=20(#2309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/e2e/CLAUDE.md | 20 ++ test/e2e/coverage.test.ts | 29 +++ test/e2e/fixtures/dual-era-stdio-server.ts | 29 +++ test/e2e/helpers/index.ts | 194 +++++++++++++++++- test/e2e/helpers/verifies.ts | 20 +- test/e2e/requirements.ts | 160 +++++++++++++++ .../scenarios/hosting-entry-session.test.ts | 154 ++++++++++++++ .../scenarios/hosting-entry-stamping.test.ts | 160 +++++++++++++++ .../scenarios/hosting-entry-streaming.test.ts | 153 ++++++++++++++ test/e2e/scenarios/hosting-entry.test.ts | 152 ++++++++++++++ test/e2e/scenarios/stdio-dual-era.test.ts | 88 ++++++++ test/e2e/types.ts | 94 ++++++++- 12 files changed, 1239 insertions(+), 14 deletions(-) create mode 100644 test/e2e/fixtures/dual-era-stdio-server.ts create mode 100644 test/e2e/scenarios/hosting-entry-session.test.ts create mode 100644 test/e2e/scenarios/hosting-entry-stamping.test.ts create mode 100644 test/e2e/scenarios/hosting-entry-streaming.test.ts create mode 100644 test/e2e/scenarios/hosting-entry.test.ts create mode 100644 test/e2e/scenarios/stdio-dual-era.test.ts diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md index 7ecb2e06e4..c72d8f2a6e 100644 --- a/test/e2e/CLAUDE.md +++ b/test/e2e/CLAUDE.md @@ -58,6 +58,26 @@ note: 'stateless hosting has no server→client back-channel' `addedInSpecVersion` / `removedInSpecVersion` bound the spec versions a requirement applies to. A behavior changed by a spec release gets a sibling entry: the new entry lists every retired id it replaces in `supersedes` (an array, requires `addedInSpecVersion`), and each retired entry points back via `supersededBy` (requires `removedInSpecVersion`). A coverage gate enforces that the links resolve and are exactly symmetric. +## The createMcpHandler entry arms (entryStateless / entryModern) + +Two transport arms host the dual-era HTTP entry (`createMcpHandler`) in process via an injected fetch, exactly like the other HTTP arms. They are era-fixed (`TRANSPORT_SPEC_VERSIONS`), so each registers cells on exactly one spec-version axis: + +- `entryStateless` — the entry with the `legacy: 'stateless'` slot; the scenario's plain client is served per request through the slot. Cells run on the 2025-11-25 axis only. +- `entryModern` — the entry modern-only strict (no legacy slot); the scenario's client is put into pinned 2026-07-28 negotiation by the arm and the per-request `_meta` envelope is attached to every outgoing request/notification by the arm (a harness stop-gap until the client + emits it itself). Cells run on the 2026-07-28 axis only. + +Both arms are part of the default transport list, so unrestricted requirements run through the entry automatically. When a requirement cannot run on an entry arm, annotate it with a machine-readable reason instead of bending the test: + +```ts +entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' /* optional note */ }]; +``` + +Omitting `arm` excludes both arms. The reasons (`EntryExclusionReason` in types.ts) are the acceptance checklist for re-admitting cells when the corresponding entry feature lands; a coverage gate rejects annotations that would never have an effect. Requirement families that the +per-request entry structurally cannot serve at all (server→client requests, sessions/resumability, standalone GET streams, subscriptions) are already expressed through their `transports` restrictions and need no annotation. + +Arm-specific helpers: `wire()`'s fourth argument also accepts `entry` (createMcpHandler hosting overrides — e.g. a `responseMode` or a bring-your-own `legacy` slot value), the returned `Wired.httpLog` records every HTTP exchange (request body, status, content-type, a readable +response clone) for raw wire assertions, factories may accept the optional per-request context (`EntryServerFactory`), and `modernEnvelopeMeta()` builds the envelope for bodies that POST raw 2026-era requests through `wired.fetch`. + ## Running From the repo root (the suite is the `@modelcontextprotocol/test-e2e` workspace package): diff --git a/test/e2e/coverage.test.ts b/test/e2e/coverage.test.ts index ed580b9a74..4397ae5420 100644 --- a/test/e2e/coverage.test.ts +++ b/test/e2e/coverage.test.ts @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url'; import { expect, test } from 'vitest'; import { REQUIREMENTS } from './requirements.js'; +import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, ENTRY_TRANSPORTS, TRANSPORT_SPEC_VERSIONS } from './types.js'; const E2E_DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -88,6 +89,34 @@ test('every transport-restricted requirement explains why in note', () => { expect(missing).toEqual([]); }); +test('every entryExclusions annotation targets an entry arm the requirement would otherwise run on', () => { + const bad: string[] = []; + for (const [id, r] of Object.entries(REQUIREMENTS)) { + for (const exclusion of r.entryExclusions ?? []) { + const arms = exclusion.arm === undefined ? ENTRY_TRANSPORTS : [exclusion.arm]; + for (const arm of arms) { + const transports = r.transports ?? ALL_TRANSPORTS; + if (!transports.includes(arm)) { + bad.push(`${id}: entryExclusions targets '${arm}', which the requirement's transports never include`); + continue; + } + const versions = ALL_SPEC_VERSIONS.filter( + v => + (r.addedInSpecVersion === undefined || v >= r.addedInSpecVersion) && + (r.removedInSpecVersion === undefined || v < r.removedInSpecVersion) && + (TRANSPORT_SPEC_VERSIONS[arm]?.includes(v) ?? true) + ); + if (versions.length === 0) { + bad.push( + `${id}: entryExclusions targets '${arm}', which registers no cells within the requirement's spec-version bounds` + ); + } + } + } + } + expect(bad).toEqual([]); +}); + test('supersedes/supersededBy links are symmetric and resolve', () => { const bad: string[] = []; for (const [id, req] of Object.entries(REQUIREMENTS)) { diff --git a/test/e2e/fixtures/dual-era-stdio-server.ts b/test/e2e/fixtures/dual-era-stdio-server.ts new file mode 100644 index 0000000000..b99b9763a9 --- /dev/null +++ b/test/e2e/fixtures/dual-era-stdio-server.ts @@ -0,0 +1,29 @@ +/** + * Runnable dual-era stdio MCP server fixture for the dual-era stdio e2e cells. + * + * `eraSupport: 'dual-era'` is the single declared act on an otherwise ordinary + * hand-constructed McpServer connected to the unchanged StdioServerTransport. + * Spawned as a real child process (via tsx) by + * test/e2e/scenarios/stdio-dual-era.test.ts; exits when its stdin reaches EOF. + */ + +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { z } from 'zod/v4'; + +const server = new McpServer( + { name: 'dual-era-stdio-e2e-fixture', version: '1.0.0' }, + { capabilities: { tools: {} }, eraSupport: 'dual-era' } +); + +server.registerTool( + 'echo', + { + description: 'Echoes the input text back as a text content block.', + inputSchema: z.object({ text: z.string() }) + }, + ({ text }) => ({ content: [{ type: 'text', text }] }) +); + +await server.connect(new StdioServerTransport()); +process.stderr.write('[dual-era-stdio-server] ready\n'); diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 0fe566be8c..afd70b38a8 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -15,30 +15,93 @@ import { PassThrough } from 'node:stream'; import type { Client } from '@modelcontextprotocol/client'; import { SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import type { EventStore, JSONRPCMessage, McpServer, Server } from '@modelcontextprotocol/server'; -import { InMemoryTransport, ReadBuffer, serializeMessage, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { + CreateMcpHandlerOptions, + EventStore, + Implementation, + JSONRPCMessage, + McpRequestContext, + McpServer, + Server, + Transport as SdkTransport +} from '@modelcontextprotocol/server'; +import { + createMcpHandler, + InMemoryTransport, + ReadBuffer, + serializeMessage, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import type { Transport } from '../types.js'; +import type { SpecVersion, Transport } from '../types.js'; import { startLegacySseHost } from './sse-host.js'; import type { SnifferOptions } from './wire-sniffer.js'; import { sniffTransport } from './wire-sniffer.js'; export type ServerFactory = () => McpServer | Server; +/** + * A factory that optionally consumes the createMcpHandler per-request context. + * The context is only supplied on the entry arms (where the entry constructs a + * fresh instance per request); on every other arm the factory is called with no + * arguments, so declare the parameter optional. + */ +export type EntryServerFactory = (ctx?: McpRequestContext) => McpServer | Server; + +/** One HTTP exchange recorded by the entry arms (see {@linkcode Wired.httpLog}). */ +export interface RecordedHttpExchange { + /** HTTP request method (GET/POST/DELETE). */ + method: string; + /** The request body text, when one was sent as a string. */ + requestBody?: string; + /** HTTP response status. */ + status: number; + /** Response content-type header (empty string when absent). */ + contentType: string; + /** An unread clone of the HTTP response, for byte-level assertions (`await exchange.response.text()`). */ + response: Response; +} + export interface Wired extends AsyncDisposable { readonly fetch?: (url: URL | string, init?: RequestInit) => Promise; readonly url?: URL; + /** + * Every HTTP exchange the wired client performed, in order, including the + * connect-time negotiation. Recorded by the createMcpHandler entry arms + * only — scenarios on those arms use it to assert raw wire facts (request + * bodies, response status/content-type/bytes) that the typed client API + * does not expose. + */ + readonly httpLog?: readonly RecordedHttpExchange[]; } /** - * The fourth argument controls the wire-format sniffer (see wire-sniffer.ts): - * every message the client sends or receives is validated against the SDK's - * spec-anchored Zod schemas. Tests that intentionally use vendor-extension - * methods pass `{ allowCustomMethods: true }`; tests that deliberately put - * malformed MCP on the wire pass `{ strictValidation: false }`. + * The fourth argument's sniffer options control the wire-format sniffer (see + * wire-sniffer.ts): every message the client sends or receives is validated + * against the SDK's spec-anchored Zod schemas. Tests that intentionally use + * vendor-extension methods pass `{ allowCustomMethods: true }`; tests that + * deliberately put malformed MCP on the wire pass `{ strictValidation: false }`. + * `entry` overrides the hosting options of the createMcpHandler entry arms + * (ignored by every other transport). */ -export async function wire(transport: Transport, makeServer: ServerFactory, client: Client, sniff: SnifferOptions = {}): Promise { +export interface WireOptions extends SnifferOptions { + /** + * createMcpHandler hosting overrides for the entry arms. Defaults: + * `{ legacy: 'stateless' }` on entryStateless (the canonical slot value) and + * modern-only strict (no legacy slot) on entryModern. `onerror` and + * `responseMode` pass through unchanged. + */ + entry?: CreateMcpHandlerOptions; +} + +export async function wire( + transport: Transport, + makeServer: ServerFactory | EntryServerFactory, + client: Client, + sniff: WireOptions = {} +): Promise { switch (transport) { case 'inMemory': { const server = makeServer(); @@ -67,6 +130,47 @@ export async function wire(transport: Transport, makeServer: ServerFactory, clie [Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {}) }; } + case 'entryStateless': + case 'entryModern': { + // The dual-era HTTP entry (`createMcpHandler`) hosted in process via an + // injected fetch, exactly like the other HTTP arms. The scenario factory + // backs the entry directly (the entry calls it once per request with its + // per-request context). `entryStateless` serves the scenario's plain + // client through the entry's `legacy: 'stateless'` slot; `entryModern` + // keeps the endpoint modern-only strict and connects the client on the + // 2026-07-28 revision (pin-mode negotiation + the per-request envelope + // stop-gap). Every HTTP exchange is recorded on `httpLog`. + const handler = createMcpHandler( + makeServer, + transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { ...sniff.entry } + ); + const url = new URL('http://in-process/mcp'); + const httpLog: RecordedHttpExchange[] = []; + const fetch = async (u: URL | string, init?: RequestInit) => { + const request = new Request(u, init); + const response = await handler.fetch(request); + httpLog.push({ + method: request.method.toUpperCase(), + ...(typeof init?.body === 'string' && { requestBody: init.body }), + status: response.status, + contentType: response.headers.get('content-type') ?? '', + response: response.clone() + }); + return response; + }; + let clientTx = new StreamableHTTPClientTransport(url, { fetch }); + if (transport === 'entryModern') { + pinModernNegotiation(client); + clientTx = attachModernEnvelope(clientTx); + } + await client.connect(sniffTransport(clientTx, 'client', sniff)); + return { + fetch, + url, + httpLog, + [Symbol.asyncDispose]: () => Promise.all([client.close(), handler.close()]).then(() => {}) + }; + } case 'sse': { // The legacy SSE transport needs a real socket: the factory's server is hosted on the // shipped SSEServerTransport (@modelcontextprotocol/server-legacy/sse) behind a loopback @@ -212,6 +316,78 @@ export function hostStateless(makeServer: ServerFactory): { handleRequest: HttpH }; } +// ─────────────────────────────────────────────────────────────────────────────── +// createMcpHandler entry arms (entryStateless / entryModern) — client-side shims +// ─────────────────────────────────────────────────────────────────────────────── + +/** The protocol revision the entryModern arm negotiates and claims per request. */ +const MODERN_REVISION: SpecVersion = '2026-07-28'; + +/** + * The per-request `_meta` envelope of a 2026-07-28 request, for scenario bodies + * that put raw HTTP requests on the wire (via `wired.fetch`) rather than going + * through the wired client. Typed calls through the wired client never need + * this — the entryModern arm attaches the envelope itself (see + * {@linkcode attachModernEnvelope}). + */ +export function modernEnvelopeMeta(clientInfo?: Implementation): Record { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: clientInfo ?? { name: 'e2e-entry-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; +} + +/** + * Put the (already constructed) scenario client into pinned 2026-07-28 + * negotiation. Version negotiation is a constructor-only option and the + * scenario corpus constructs era-agnostic clients, so the entryModern arm flips + * the option on the instance before `connect()` — a harness stop-gap, not a + * public API. Clients that already opted into a negotiation mode are left + * untouched (their cells deliberately exercise that mode). + */ +function pinModernNegotiation(client: Client): void { + const internals = client as unknown as { _versionNegotiation?: { mode?: unknown } }; + internals._versionNegotiation ??= { mode: { pin: MODERN_REVISION } }; +} + +/** + * The per-request `_meta` envelope stop-gap for the entryModern arm: the + * negotiating client only attaches the envelope to its `server/discover` probe + * today (automatic per-request emission is a client-side follow-up), so the + * harness re-attaches the same envelope to every later request and notification + * the scenario's typed calls put on the wire. The envelope is captured from the + * probe itself, so it always matches what the client actually claimed; messages + * that already carry a protocol-version claim (the probe, or a scenario's + * explicitly enveloped request) pass through untouched. + * + * Applied beneath the wire sniffer and `tapWire`, so recorded traffic shows the + * messages exactly as the scenario sent them while the wire carries the + * envelope the entry requires. + */ +function attachModernEnvelope(transport: T): T { + let envelope: Record | undefined; + const origSend = transport.send.bind(transport); + transport.send = async (message, opts) => { + let outbound = message; + if ('method' in message) { + const params = (message.params ?? {}) as { _meta?: Record }; + const meta = params._meta; + if (meta?.[PROTOCOL_VERSION_META_KEY] !== undefined) { + envelope ??= { + [PROTOCOL_VERSION_META_KEY]: meta[PROTOCOL_VERSION_META_KEY], + [CLIENT_INFO_META_KEY]: meta[CLIENT_INFO_META_KEY], + [CLIENT_CAPABILITIES_META_KEY]: meta[CLIENT_CAPABILITIES_META_KEY] + }; + } else if (envelope !== undefined) { + outbound = { ...message, params: { ...params, _meta: { ...envelope, ...meta } } }; + } + } + return origSend(outbound, opts); + }; + return transport; +} + // ─────────────────────────────────────────────────────────────────────────────── // In-process stdio client — TEST-ONLY // diff --git a/test/e2e/helpers/verifies.ts b/test/e2e/helpers/verifies.ts index bfcdc47216..0f2d07bdc4 100644 --- a/test/e2e/helpers/verifies.ts +++ b/test/e2e/helpers/verifies.ts @@ -18,11 +18,23 @@ import { describe, test } from 'vitest'; import { REQUIREMENTS } from '../requirements.js'; -import type { TestArgs } from '../types.js'; -import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS } from '../types.js'; +import type { Requirement, SpecVersion, TestArgs, Transport } from '../types.js'; +import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, ENTRY_TRANSPORTS, TRANSPORT_SPEC_VERSIONS } from '../types.js'; type TestBody = (args: TestArgs) => Promise; +/** Whether a requirement's `entryExclusions` keep the given entry arm out of its cells. */ +function excludedFromEntryArm(req: Requirement, transport: Transport): boolean { + if (!(ENTRY_TRANSPORTS as readonly Transport[]).includes(transport)) return false; + return (req.entryExclusions ?? []).some(x => x.arm === undefined || x.arm === transport); +} + +/** Whether a transport arm serves the given spec version (era-fixed arms serve exactly one). */ +function transportServesVersion(transport: Transport, version: SpecVersion): boolean { + const versions = TRANSPORT_SPEC_VERSIONS[transport]; + return versions === undefined || versions.includes(version); +} + export function verifies(id: string | readonly string[], fn: TestBody, opts?: { title?: string }): void { const ids = Array.isArray(id) ? id : [id]; for (const rid of ids) registerOne(rid, fn, opts); @@ -33,13 +45,13 @@ function registerOne(id: string, fn: TestBody, opts?: { title?: string }): void if (!req) throw new Error(`verifies('${id}'): unknown requirement id`); if (req.deferred) throw new Error(`verifies('${id}'): requirement is deferred — drop the deferral or the test`); - const transports = req.transports ?? ALL_TRANSPORTS; + const transports = (req.transports ?? ALL_TRANSPORTS).filter(t => !excludedFromEntryArm(req, t)); const versions = ALL_SPEC_VERSIONS.filter( v => (req.addedInSpecVersion === undefined || v >= req.addedInSpecVersion) && (req.removedInSpecVersion === undefined || v < req.removedInSpecVersion) ); - const cells = versions.flatMap(v => transports.map(t => [t, v] as const)); + const cells = versions.flatMap(v => transports.filter(t => transportServesVersion(t, v)).map(t => [t, v] as const)); describe.each(cells)(`${id} [%s %s]`, (transport, protocolVersion) => { const kf = req.knownFailures?.find( diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 7f5d68077e..567c55e023 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -26,6 +26,7 @@ export const REQUIREMENTS: Record = { behavior: 'The client rejects calls to methods (e.g. resources/list) for capabilities the server did not advertise.' }, 'lifecycle:initialize:basic': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization', behavior: 'Connecting sends initialize with the protocol version, client capabilities, and client info; the server responds with its own and the connection is established.' @@ -35,24 +36,29 @@ export const REQUIREMENTS: Record = { behavior: 'A server may include an instructions string in the initialize result; the client exposes it.' }, 'lifecycle:initialized-notification': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization', behavior: 'After successful initialization, the client sends exactly one initialized notification, before any non-ping request.' }, 'lifecycle:ping': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#behavior-requirements', behavior: 'ping in either direction returns an empty result.' }, 'lifecycle:version:downgrade': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'When the server returns an older supported protocol version, the client downgrades to it and the connection succeeds at that version.' }, 'lifecycle:version:match': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'When the server supports the requested protocol version it echoes that version in the initialize result, and the connection proceeds at that version.' }, 'lifecycle:version:reject-unsupported': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'When server returns a protocolVersion the client does not support, connect rejects and the transport is closed.', knownFailures: [ @@ -87,11 +93,13 @@ export const REQUIREMENTS: Record = { note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, 'lifecycle:version:server-fallback-latest': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'An initialize request carrying a protocol version the server does not support is answered with another version the server supports — the latest one — rather than an error.' }, 'lifecycle:pre-initialization-ordering': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization', behavior: 'Before initialization completes, the client sends no requests other than pings, and the server sends no requests other than pings and logging.' @@ -150,6 +158,13 @@ export const REQUIREMENTS: Record = { ] }, 'protocol:cancel:unknown-id-ignored': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'method-not-in-modern-registry', + note: 'The body proves liveness after the ignored cancellation with ping, which the 2026-07-28 registry deletes; the ignored-cancellation behavior itself is still modern.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#error-handling', behavior: 'The receiver silently ignores a cancellation notification referencing an unknown or already-completed request id; no error response is sent and no exception is raised.' @@ -180,6 +195,7 @@ export const REQUIREMENTS: Record = { behavior: 'A request with malformed params is answered with JSON-RPC error -32602 Invalid params.' }, 'protocol:error:method-not-found': { + entryExclusions: [{ arm: 'entryModern', reason: 'modern-error-surface' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#responses', behavior: 'A request whose method has no registered handler is answered with a METHOD_NOT_FOUND error.' }, @@ -237,6 +253,13 @@ export const REQUIREMENTS: Record = { behavior: 'When a request times out, the sender issues notifications/cancelled for that request before failing the local call.' }, 'mcpserver:onerror:reach-through': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'requires-session', + note: 'The body delivers stray responses to a connected instance; on the modern path the entry classifier rejects posted responses before any per-request instance exists.' + } + ], source: 'sdk', behavior: 'Setting mcpServer.server.onerror (or server.onerror on raw Server) receives both transport-level errors and protocol/handler errors (uncaught notification handler, failed-to-send-response, unknown-message-id). The reach-through via McpServer.server is the supported access path until McpServer exposes onerror directly.' @@ -254,6 +277,13 @@ export const REQUIREMENTS: Record = { "A user-defined request schema registered via server.setRequestHandler(CustomSchema, h) is dispatched when client.request({method:'x/custom', params}, CustomResultSchema) is called; the handler's return value is parsed by the result schema and resolved to the caller. Capability checks do not reject non-spec method names." }, 'protocol:custom-method:roundtrip': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'modern-error-surface', + note: 'The custom-method round trip itself serves fine; the body also asserts the -32601 surface for a never-registered method, which differs on the modern path.' + } + ], source: 'sdk', behavior: "server.setRequestHandler with a schema whose method literal is NOT in the MCP spec registers a handler; client.request({method:''}, ResultSchema) returns the handler's result, not -32601 MethodNotFound. Capability assertions on both sides pass through unknown methods." @@ -281,6 +311,7 @@ export const REQUIREMENTS: Record = { note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, 'protocol:request-handler:override-builtin': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'sdk', behavior: 'server.setRequestHandler() for a spec method that has a built-in handler (initialize, ping, logging/setLevel) replaces that handler; the user-supplied result is what the client receives. No throw on re-registration.' @@ -358,6 +389,13 @@ export const REQUIREMENTS: Record = { behavior: 'tools/call for a name the server does not recognise returns a JSON-RPC error.' }, 'tools:capability:declared': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'server/discover deliberately omits the listChanged capability flag this body asserts.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#capabilities', behavior: 'A server that exposes tools declares the tools capability (optionally with listChanged) in its InitializeResult.' }, @@ -389,6 +427,13 @@ export const REQUIREMENTS: Record = { behavior: 'tools/list returns the registered tools with name, description, and inputSchema.' }, 'tools:list:metadata': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'The 2026-07-28 wire deletes tools[].execution (taskSupport), which this body asserts round-trips.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool', behavior: 'tools/list includes title, annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), _meta, icons, and execution.taskSupport when set.' @@ -498,6 +543,13 @@ export const REQUIREMENTS: Record = { 'Resources, resource templates, and resource contents may carry annotations {audience, priority, lastModified}; these round-trip from server registration to the client list/read result.' }, 'resources:capability:declared': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'server/discover deliberately omits the listChanged capability flag this body asserts.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#capabilities', behavior: 'A server with resource handlers advertises the resources capability, including the subscribe sub-flag when a subscribe handler is registered.' @@ -541,6 +593,7 @@ export const REQUIREMENTS: Record = { behavior: 'resources/read for an unknown URI returns JSON-RPC error -32002 (resource not found).' }, 'resources:subscribe:capability-required': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#capabilities', behavior: 'resources/subscribe to a server that did not advertise the subscribe capability is rejected with an error.' }, @@ -605,6 +658,13 @@ export const REQUIREMENTS: Record = { // Prompts 'prompts:capability:declared': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'server/discover deliberately omits the listChanged capability flag this body asserts.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#capabilities', behavior: 'A server with a list_prompts handler advertises the prompts capability in its initialize result.' }, @@ -716,6 +776,7 @@ export const REQUIREMENTS: Record = { behavior: 'The completion result carries values (at most 100), an optional total, and an optional hasMore flag.' }, 'completion:complete:not-supported': { + entryExclusions: [{ arm: 'entryModern', reason: 'modern-error-surface' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#capabilities', behavior: 'A server with no completion handler does not advertise the completions capability and rejects completion/complete with METHOD_NOT_FOUND.' @@ -733,6 +794,13 @@ export const REQUIREMENTS: Record = { behavior: 'A server that emits log message notifications declares the logging capability in its initialize result.' }, 'logging:message:fields': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'method-not-in-modern-registry', + note: 'The body scaffolds the exchange with logging/setLevel, which the 2026-07-28 registry deletes; notifications/message itself is still modern vocabulary.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#log-message-notifications', behavior: "A log message sent by a server handler is delivered to the client's logging callback with its severity level, logger name, and data." @@ -750,6 +818,7 @@ export const REQUIREMENTS: Record = { note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, 'logging:set-level:invalid-level': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#error-handling', behavior: 'logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).', knownFailures: [ @@ -1134,11 +1203,13 @@ export const REQUIREMENTS: Record = { behavior: "_meta returned in a handler's result is delivered intact to the requesting client." }, 'protocol:request-id:unique': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#requests', behavior: 'Every request sent on a session carries a unique, non-null string or integer id; ids are never reused within the session.' }, 'protocol:notifications:no-response': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#notifications', behavior: 'Notifications are never answered: every message the server delivers is either the response to a request the client sent or a notification carrying no id.' @@ -2195,6 +2266,86 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. The allowed-host control asserts initialize semantics per spec version: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' }, + + // v2 features: dual-era serving (createMcpHandler entry, eraSupport stdio, result stamping) + + 'typescript:hosting:entry:dual-era-one-factory': { + source: 'sdk', + behavior: + 'createMcpHandler serves one ctx-taking factory to both protocol eras on one endpoint: with the legacy "stateless" slot configured, a plain client is served per request via initialize, tools/list and tools/call on the 2025 era, and an auto-negotiating client reaches 2026-07-28 via server/discover (never initialize) and gets tools/call served with the per-request _meta envelope.', + transports: ['entryStateless', 'entryModern'], + note: 'Runs on the createMcpHandler entry arms (the same one-factory, legacy-stateless-slot handler shape on both): the entryStateless cell drives the 2025 leg through the slot and the entryModern cell drives the modern path, with the never-initialize/server-discover clauses asserted on the arm-recorded HTTP exchanges.' + }, + 'typescript:hosting:entry:pin-negotiation': { + source: 'sdk', + behavior: + 'A client pinned to the 2026-07-28 revision (versionNegotiation mode pin) connects to a strict createMcpHandler endpoint without ever sending initialize — its first request is server/discover — and an enveloped tools/call round-trips.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm (modern-only strict is its default hosting); the body constructs the pinned client itself and asserts the never-initialize, discover-first and envelope clauses on the arm-recorded HTTP exchanges.' + }, + 'typescript:hosting:entry:strict-rejects-legacy': { + source: 'sdk', + behavior: + 'A createMcpHandler endpoint with no legacy slot configured (modern-only strict) rejects a 2025-shaped initialize with the unsupported-protocol-version error carrying the supported modern revisions in error.data.supported; nothing is silently served on the 2025 era.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm (modern-only strict is its default hosting); the 2025-shaped initialize and the plain-client connect attempt are driven against the harness-hosted endpoint via wired.fetch/wired.url. The numeric error code is asserted by message and supported-list shape only, since it shares a code with the still-disputed header/body mismatch family.' + }, + 'typescript:hosting:entry:notification-202': { + source: 'sdk', + behavior: + 'A POST carrying only a notification is answered 202 Accepted with an empty body by a createMcpHandler endpoint on both legs: an envelope-less notification through the legacy stateless slot and an envelope-carrying notification on the modern path.', + transports: ['entryStateless', 'entryModern'], + note: 'Runs on the createMcpHandler entry arms; each cell POSTs the raw notification through wired.fetch so the HTTP contract (status code and empty body) is observed directly, and the arm selects which leg the notification rides. Delivery of the notification to the per-request server instance is pinned at unit level.' + }, + 'typescript:hosting:entry:modern-cacheable-stamping': { + source: 'sdk', + behavior: + 'Typed tools/list, resources/read and resources/list round trips negotiated on 2026-07-28 over a createMcpHandler endpoint succeed, and the wire results carry resultType "complete" plus the required ttlMs/cacheScope fields, with the configured-hint precedence observable on the wire: the per-resource cacheHint wins over the per-operation cacheHints entry (resources/read), a per-operation hint wins over the defaults (tools/list), and a result with no configured author is filled with the ttlMs 0 / cacheScope private defaults (resources/list).', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the typed round trips go through the wired negotiating client and the wire-level stamping is asserted on the arm-recorded response bytes. The top precedence rung — a handler-returned ttlMs/cacheScope value winning over every configured hint — is pinned at unit level and not exercised here.' + }, + 'typescript:hosting:entry:legacy-cacheable-suppression': { + source: 'sdk', + behavior: + 'A factory with every cache-hint author configured (per-operation cacheHints and a per-resource cacheHint), served to a plain 2025 client through the legacy stateless slot of a createMcpHandler endpoint, answers tools/list and resources/read with no resultType, ttlMs, cacheScope or cacheHint vocabulary anywhere in the response bytes.', + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + note: 'The suppression invariant is a statement about 2025-era serving, so the requirement is bounded to the 2025-11-25 axis and runs on the entryStateless arm; the response bytes are asserted on the arm-recorded HTTP exchanges.' + }, + 'typescript:hosting:entry:byo-sessionful-legacy': { + source: 'sdk', + behavior: + 'A real sessionful legacy wiring (per-session WebStandardStreamableHTTPServerTransport instances keyed by Mcp-Session-Id) passed as the createMcpHandler legacy slot value serves the full 2025-era session lifecycle through the entry: initialize issues an Mcp-Session-Id, a follow-up POST is served on that session, GET opens the standalone SSE stream, and DELETE tears the session down (a request carrying the dead session id answers 404).', + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + note: "The lifecycle is a statement about 2025-era serving through the bring-your-own legacy slot, so the requirement is bounded to the 2025-11-25 axis and runs on the entryStateless arm with the slot overridden via wire()'s entry.legacy option. It pins the entry routing of body-less GET and DELETE to the bring-your-own legacy slot, observed at the slot as method/status/content-type; byte-level forwarding fidelity is not asserted." + }, + 'typescript:hosting:entry:modern-lazy-sse-upgrade': { + source: 'sdk', + behavior: + 'On the default response mode, a modern (2026-07-28) request exchange over a createMcpHandler endpoint is answered as a single JSON body when the handler emits nothing before its result, and upgrades to an SSE stream when the handler emits related notifications mid-call: the response content-type becomes text/event-stream and the frames carry the notifications in emission order with the terminal result as the last frame.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the typed calls go through the wired negotiating client and the response shape (status, content-type, SSE frame order) is asserted on the arm-recorded HTTP exchanges.' + }, + 'typescript:hosting:entry:modern-response-mode': { + source: 'sdk', + behavior: + 'The createMcpHandler responseMode option shapes modern (2026-07-28) request exchanges end to end: "sse" answers over an SSE stream even when the handler emits nothing before its result, and "json" answers with a single JSON body whose only payload is the terminal result — mid-call notifications are dropped, not buffered.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: "Runs on the entryModern arm; the body wires one harness-hosted endpoint per responseMode value via wire()'s entry.responseMode option and asserts the response shape on the arm-recorded HTTP exchanges." + }, + 'typescript:transport:stdio:dual-era-serving': { + source: 'sdk', + behavior: + 'A hand-constructed stdio server declaring eraSupport "dual-era" (transport line unchanged) serves a plain 2025 client via initialize and an auto-negotiating client on 2026-07-28 via server/discover, over a real child-process pipe.', + transports: ['stdio'], + note: 'Dual-era stdio serving is exercised against a real spawned child process (fixtures/dual-era-stdio-server.ts), so the matrix transport arg is ignored and the requirement lists stdio only; the spec-version axis selects which client drives the cell.' + }, 'custom-methods:server-handler:roundtrip': { source: 'sdk', behavior: @@ -2220,6 +2371,7 @@ export const REQUIREMENTS: Record = { note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' }, 'typescript:method-string-handlers:result-type-inference': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'sdk', behavior: 'client.request() called with a spec method string and no result schema resolves with the result already parsed and validated for that method (ResultTypeMap inference), e.g. tools/list yields a usable tools array without passing a schema.' @@ -2323,11 +2475,13 @@ export const REQUIREMENTS: Record = { note: "This exercises the HTTP client transport's reconnection path; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs." }, 'lifecycle:version:custom-supported-versions': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'sdk', behavior: 'supportedProtocolVersions passed in Client/Server options overrides the negotiation list: a client requesting a version the server supports gets that version back, and both sides report the negotiated version after connect.' }, 'lifecycle:version:no-overlap-rejects': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'sdk', behavior: "When the server's negotiated protocol version is not in the client's supportedProtocolVersions list, client.connect() rejects and the connection is not established." @@ -2382,6 +2536,12 @@ export const REQUIREMENTS: Record = { note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'transport:standalone:raw-relay': { + entryExclusions: [ + { + reason: 'drives-transport-directly', + note: 'The body builds and hosts its own raw transports per matrix arm; an entry cell would re-run the streamable HTTP relay without exercising the entry.' + } + ], source: 'sdk', behavior: 'Client and server transports can be driven directly (start/send/onmessage/onclose/onerror) without wrapping them in a Client or Server, supporting message-relay proxies.', diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts new file mode 100644 index 0000000000..40d042104b --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -0,0 +1,154 @@ +/** + * Sessionful 2025-era serving through the dual-era HTTP entry's + * bring-your-own legacy slot, exercised on the wire() entryStateless arm with + * the slot overridden via `wire()`'s `entry.legacy` option. + * + * The legacy slot value is a real sessionful wiring — one + * WebStandardStreamableHTTPServerTransport per session, kept in a map keyed by + * the Mcp-Session-Id the transport itself issues (the documented sessionful + * hosting pattern) — and a plain 2025 SDK client drives the full session + * lifecycle through the harness-hosted `createMcpHandler`: initialize issues a + * session id, a follow-up POST is served on that session, the body-less GET + * opens the standalone SSE stream, and DELETE tears the session down. Every + * exchange the slot serves is recorded as it leaves the wiring (method, status, + * content-type), so the entry's routing of GET/DELETE (no envelope, no body → + * legacy slot) to the bring-your-own handler is pinned directly; byte-level + * forwarding fidelity is not asserted here. + */ +import { randomUUID } from 'node:crypto'; + +import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { Client } from '@modelcontextprotocol/client'; +import type { LegacyHttpHandler, McpHandlerRequestOptions, McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; + +/** The factory backing the modern path; this cell never drives it (the lifecycle under test is the legacy slot's). */ +function modernFactory(_ctx?: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry-session', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (modern)` }] + })); + return server; +} + +verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: TestArgs) => { + // The documented sessionful wiring, passed as the bring-your-own legacy + // slot value: a fresh transport per initialize, kept in a map keyed by the + // Mcp-Session-Id it issues; later requests are routed by that header. + const sessions = new Map(); + const closedSessions: string[] = []; + const sessionServers: McpServer[] = []; + + async function routeSessionRequest(request: Request, options?: McpHandlerRequestOptions): Promise { + const sessionId = request.headers.get('mcp-session-id'); + if (sessionId !== null) { + const existing = sessions.get(sessionId); + if (existing !== undefined) return existing.handleRequest(request, options); + // A request for a session this wiring no longer (or never) knew — + // the documented sessionful pattern answers 404. + return Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 }); + } + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: id => void sessions.set(id, transport), + onsessionclosed: id => { + closedSessions.push(id); + sessions.delete(id); + } + }); + const server = new McpServer({ name: 'byo-session-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (byo session)` }] + })); + sessionServers.push(server); + await server.connect(transport); + return transport.handleRequest(request, options); + } + + // Every exchange the entry forwards to the bring-your-own slot, recorded + // as it leaves the wiring: this is what proves the GET/DELETE routing. + const slotExchanges: Array<{ method: string; status: number; contentType: string }> = []; + const sessionfulLegacy: LegacyHttpHandler = async (request, options) => { + const response = await routeSessionRequest(request, options); + slotExchanges.push({ + method: request.method.toUpperCase(), + status: response.status, + contentType: response.headers.get('content-type') ?? '' + }); + return response; + }; + + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + try { + // The harness hosts the entry; the bring-your-own wiring replaces the + // arm's default 'stateless' slot value. + await using wired = await wire(transport, modernFactory, client, { entry: { legacy: sessionfulLegacy } }); + + // initialize → the bring-your-own transport issues an Mcp-Session-Id. + // (The stateless slot never issues one, so a defined session id alone + // proves the request reached the bring-your-own wiring.) + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + const clientTransport = client.transport as StreamableHTTPClientTransport; + const sessionId = clientTransport.sessionId; + expect(sessionId).toBeDefined(); + expect(sessions.has(sessionId!)).toBe(true); + + // Follow-up POST on the session: served by the same per-session instance. + const result = await client.callTool({ name: 'greet', arguments: { name: 'session friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello session friend (byo session)' }]); + expect(clientTransport.sessionId).toBe(sessionId); + + // GET route: the client opens its standalone SSE stream after + // initialization; the entry routes the body-less GET (no envelope) to + // the legacy slot, which answers it with the stream. + await vi.waitFor( + () => { + const get = slotExchanges.find(exchange => exchange.method === 'GET'); + if (get === undefined) throw new Error('the standalone GET stream has not reached the legacy slot yet'); + expect(get.status).toBe(200); + expect(get.contentType).toContain('text/event-stream'); + }, + { timeout: 5000, interval: 50 } + ); + + // DELETE route: terminating the session goes through the entry to the + // bring-your-own transport, which tears the session down. + await clientTransport.terminateSession(); + expect(closedSessions).toEqual([sessionId]); + const deleteExchange = slotExchanges.find(exchange => exchange.method === 'DELETE'); + expect(deleteExchange?.status).toBe(200); + + // Stop the client before probing the dead session so its standalone + // stream cannot reconnect underneath the assertion. + await client.close(); + + // The dead session is gone: a POST carrying its id is answered 404 by + // the bring-your-own wiring, not silently re-served by anything else. + const stale = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId!, + 'mcp-protocol-version': LEGACY + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'tools/list', params: {} }) + }); + expect(stale.status).toBe(404); + await stale.text(); + // ...and that 404 was produced by the bring-your-own wiring (the probe + // reached the slot), not synthesized by the entry or anything in front of it. + expect(slotExchanges.some(exchange => exchange.method === 'POST' && exchange.status === 404)).toBe(true); + } finally { + await client.close().catch(() => {}); + for (const server of sessionServers) await server.close().catch(() => {}); + } +}); diff --git a/test/e2e/scenarios/hosting-entry-stamping.test.ts b/test/e2e/scenarios/hosting-entry-stamping.test.ts new file mode 100644 index 0000000000..6ef259ba16 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-stamping.test.ts @@ -0,0 +1,160 @@ +/** + * Result stamping and cache-field fill, end to end over the dual-era HTTP + * entry (`createMcpHandler`), with the era boundary asserted on the wire: + * + * - the entryModern cell (2026-07-28 axis): typed tools/list, resources/read + * and resources/list round trips through the negotiating client succeed, and + * the recorded wire results carry `resultType: 'complete'` plus the required + * `ttlMs`/`cacheScope` fields, with three rungs of the documented precedence + * observable on the wire: the per-resource hint wins over the per-operation + * hint (resources/read), a per-operation hint wins over the defaults + * (tools/list), and a result with no configured author is filled with the + * `{ ttlMs: 0, cacheScope: 'private' }` defaults (resources/list). The top + * rung — a handler-returned value winning over every configured hint — is + * pinned at unit level (encodeContract), not here. + * - the entryStateless cell (2025-11-25 axis): the same fully + * cache-hint-configured factory served to a plain client through the legacy + * stateless slot answers the same calls with none of that vocabulary + * anywhere in the response bytes. + * + * Both cells run through the wire() entry arms; the raw response bytes come + * from the arm-recorded `wired.httpLog`. + */ +import { Client } from '@modelcontextprotocol/client'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import type { Wired } from '../helpers/index.js'; +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; + +/** The cache-field vocabulary that must never appear on a 2025-era response. */ +const CACHE_VOCABULARY = ['"resultType"', '"ttlMs"', '"cacheScope"', '"cacheHint"'] as const; + +/** + * One ctx-taking factory with every cache-hint author configured: + * - a per-operation hint for tools/list (the funnel-built result with no other author), + * - a per-operation hint for resources/read AND a per-resource hint on the + * registered resource, so the documented precedence (per-resource wins) is + * observable on the wire. + */ +function cacheConfiguredFactory(_ctx?: McpRequestContext): McpServer { + const server = new McpServer( + { name: 'e2e-entry-cache', version: '1.0.0' }, + { + capabilities: { tools: {}, resources: {} }, + cacheHints: { + 'tools/list': { ttlMs: 60_000, cacheScope: 'public' }, + 'resources/read': { ttlMs: 90_000, cacheScope: 'public' } + } + } + ); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name}` }] + })); + server.registerResource('note', 'memo://note', { cacheHint: { ttlMs: 12_000, cacheScope: 'private' } }, async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'cached note' }] + })); + return server; +} + +/** The raw response bodies of every recorded HTTP exchange, in order. */ +function responseBodies(wired: Wired): Promise { + return Promise.all((wired.httpLog ?? []).map(exchange => exchange.response.text())); +} + +/** Parses a captured response body (plain JSON or SSE-framed) into its JSON-RPC messages. */ +function jsonRpcMessagesFrom(text: string): Array> { + if (text.trim() === '') return []; + if (text.includes('data: ')) { + return text + .split('\n') + .filter(line => line.startsWith('data: ')) + .map(line => JSON.parse(line.slice(6)) as Record); + } + try { + const parsed = JSON.parse(text) as Record | Array>; + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return []; + } +} + +/** Finds the wire result of the response message whose result carries the given key. */ +function wireResultWith(bodies: string[], key: string): Record | undefined { + for (const body of bodies) { + for (const message of jsonRpcMessagesFrom(body)) { + const result = message.result as Record | undefined; + if (result && key in result) return result; + } + } + return undefined; +} + +verifies('typescript:hosting:entry:modern-cacheable-stamping', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'e2e-stamping-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await using wired = await wire(transport, cacheConfiguredFactory, client); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // Typed round trips (the 2026 wire result schemas require the cache + // fields, so a successful decode is itself part of the assertion). + const list = await client.listTools(); + expect(list.tools.map(tool => tool.name)).toEqual(['greet']); + + const read = await client.readResource({ uri: 'memo://note' }); + const firstContent = read.contents[0]; + expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note'); + + const resourceList = await client.listResources(); + expect(resourceList.resources.map(resource => resource.uri)).toEqual(['memo://note']); + + // Wire-level: resultType is stamped and the cache fields carry the + // configured hints. tools/list has only the per-operation author (its + // hint wins over the defaults); resources/read shows the per-resource + // hint winning over the per-operation hint; resources/list has no + // configured author at all and is filled with the documented defaults. + const bodies = await responseBodies(wired); + const listResult = wireResultWith(bodies, 'tools'); + expect(listResult).toBeDefined(); + expect(listResult).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' }); + + const readResult = wireResultWith(bodies, 'contents'); + expect(readResult).toBeDefined(); + expect(readResult).toMatchObject({ resultType: 'complete', ttlMs: 12_000, cacheScope: 'private' }); + + const resourceListResult = wireResultWith(bodies, 'resources'); + expect(resourceListResult).toBeDefined(); + expect(resourceListResult).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' }); +}); + +verifies('typescript:hosting:entry:legacy-cacheable-suppression', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await using wired = await wire(transport, cacheConfiguredFactory, client); + + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + + // The same calls, typed, on the 2025 leg (served through the legacy stateless slot). + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); + const read = await client.readResource({ uri: 'memo://note' }); + const firstContent = read.contents[0]; + expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note'); + + // None of the 2026 cache vocabulary appears anywhere in the bytes of + // any response of this conversation, even though every cache-hint + // author is configured on the factory. + const bodies = await responseBodies(wired); + const conversation = bodies.join('\n'); + expect(conversation).toContain('"tools"'); + expect(conversation).toContain('"contents"'); + for (const term of CACHE_VOCABULARY) { + expect(conversation).not.toContain(term); + } +}); diff --git a/test/e2e/scenarios/hosting-entry-streaming.test.ts b/test/e2e/scenarios/hosting-entry-streaming.test.ts new file mode 100644 index 0000000000..6b6ec0c0cd --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-streaming.test.ts @@ -0,0 +1,153 @@ +/** + * Modern-era (2026-07-28) response streaming through the dual-era HTTP entry, + * exercised on the wire() entryModern arm: + * + * - default response mode: a handler that emits nothing before its result is + * answered as a single JSON body; a handler that emits related notifications + * mid-call upgrades the response to an SSE stream (content-type + * text/event-stream, notifications framed in emission order, terminal result + * last); + * - `responseMode: 'sse'` always streams, even with no mid-call output; + * - `responseMode: 'json'` never streams and drops mid-call notifications — + * only the terminal result is delivered. + * + * Every body drives the harness-hosted entry with the auto-negotiating client; + * the typed result and the raw wire bytes (status, content-type, SSE frames) + * are asserted side by side via the arm-recorded `wired.httpLog`. + */ +import { Client } from '@modelcontextprotocol/client'; +import type { CallToolResult, McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import type { Wired } from '../helpers/index.js'; +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const MODERN = '2026-07-28'; + +/** + * One factory with a quiet tool (no streamed output) and a chatty tool (two + * logging notifications emitted before its result), so the lazy upgrade and + * both forced response modes are observable per call. + */ +function streamingFactory(_ctx?: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry-streaming', version: '1.0.0' }, { capabilities: { tools: {}, logging: {} } }); + server.registerTool('quiet', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: 'quiet result' }] + })); + server.registerTool('chatty', { inputSchema: z.object({}) }, async (_args, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'first' } }); + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'second' } }); + return { content: [{ type: 'text', text: 'chatty result' }] }; + }); + return server; +} + +interface RecordedResponse { + status: number; + contentType: string; + body: string; +} + +/** Every recorded HTTP response (status, content-type, raw body bytes), in exchange order. */ +function recordedResponses(wired: Wired): Promise { + return Promise.all( + (wired.httpLog ?? []).map(async exchange => ({ + status: exchange.status, + contentType: exchange.contentType, + body: await exchange.response.text() + })) + ); +} + +/** The `data:` payloads of an SSE-framed body, parsed, in frame order. */ +function sseDataFrames(body: string): Array> { + return body + .split('\n') + .filter(line => line.startsWith('data: ')) + .map(line => JSON.parse(line.slice('data: '.length)) as Record); +} + +function newAutoClient(): Client { + return new Client({ name: 'e2e-streaming-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +} + +function callTool(client: Client, name: 'quiet' | 'chatty'): Promise { + return client.callTool({ name, arguments: {} }) as Promise; +} + +verifies('typescript:hosting:entry:modern-lazy-sse-upgrade', async ({ transport }: TestArgs) => { + const client = newAutoClient(); + await using wired = await wire(transport, streamingFactory, client); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // Quiet handler: nothing emitted before the result → a single JSON body. + const quiet = await callTool(client, 'quiet'); + expect(quiet.content).toEqual([{ type: 'text', text: 'quiet result' }]); + + // Chatty handler: the first related notification upgrades the exchange + // to SSE — notifications framed in order, terminal result last. + const chatty = await callTool(client, 'chatty'); + expect(chatty.content).toEqual([{ type: 'text', text: 'chatty result' }]); + + const responses = await recordedResponses(wired); + const quietResponse = responses.find(response => response.body.includes('quiet result')); + expect(quietResponse).toBeDefined(); + expect(quietResponse!.status).toBe(200); + expect(quietResponse!.contentType).toContain('application/json'); + + const chattyResponse = responses.find(response => response.body.includes('chatty result')); + expect(chattyResponse).toBeDefined(); + expect(chattyResponse!.status).toBe(200); + expect(chattyResponse!.contentType).toContain('text/event-stream'); + + const frames = sseDataFrames(chattyResponse!.body); + expect(frames).toHaveLength(3); + expect(frames[0]).toMatchObject({ method: 'notifications/message', params: { data: 'first' } }); + expect(frames[1]).toMatchObject({ method: 'notifications/message', params: { data: 'second' } }); + expect(frames[2]).toMatchObject({ result: { content: [{ type: 'text', text: 'chatty result' }] } }); +}); + +verifies('typescript:hosting:entry:modern-response-mode', async ({ transport }: TestArgs) => { + // One harness-hosted endpoint per responseMode value, both backed by the same factory. + + // responseMode 'sse': even a handler that emits nothing streams its result. + { + const client = newAutoClient(); + await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'sse' } }); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const result = await callTool(client, 'quiet'); + expect(result.content).toEqual([{ type: 'text', text: 'quiet result' }]); + + const responses = await recordedResponses(wired); + const response = responses.find(candidate => candidate.body.includes('quiet result')); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.contentType).toContain('text/event-stream'); + const frames = sseDataFrames(response!.body); + expect(frames).toHaveLength(1); + expect(frames[0]).toMatchObject({ result: { content: [{ type: 'text', text: 'quiet result' }] } }); + } + + // responseMode 'json': mid-call notifications are dropped — the response + // is a plain JSON body whose only payload is the terminal result. + { + const client = newAutoClient(); + await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'json' } }); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const result = await callTool(client, 'chatty'); + expect(result.content).toEqual([{ type: 'text', text: 'chatty result' }]); + + const responses = await recordedResponses(wired); + const response = responses.find(candidate => candidate.body.includes('chatty result')); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.contentType).toContain('application/json'); + expect(response!.body).not.toContain('notifications/message'); + } +}); diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts new file mode 100644 index 0000000000..e5b3cf08d8 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -0,0 +1,152 @@ +/** + * Core cells for the dual-era HTTP entry (`createMcpHandler`), exercised + * through the wire() entry arms: `entryStateless` hosts the entry's + * `legacy: 'stateless'` slot for plain 2025-era clients (2025-11-25 axis) and + * `entryModern` hosts the modern-only strict endpoint for negotiating clients + * (2026-07-28 axis). Raw wire facts (request bodies, statuses, response bytes) + * are asserted on the arm-recorded `wired.httpLog`; raw HTTP probes go through + * `wired.fetch` so every exchange still rides the harness-hosted entry. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { modernEnvelopeMeta, wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; + +/** One ctx-taking factory backing every cell: the era only shows up in the tool output so tests can see which leg served the call. */ +function greetFactory(ctx?: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (${ctx?.era ?? 'unknown'})` }] + })); + return server; +} + +verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: TestArgs) => { + // Both cells host the same handler shape — one ctx-taking factory, legacy + // 'stateless' slot configured — and differ only in the client driving it. + const client = + transport === 'entryModern' + ? new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }) + : new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await using wired = await wire(transport, greetFactory, client, { entry: { legacy: 'stateless' } }); + + if (transport === 'entryStateless') { + // 2025-era leg: a plain client is served per request through the + // legacy 'stateless' slot — initialize → tools/list → tools/call. + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); + const result = await client.callTool({ name: 'greet', arguments: { name: 'old friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello old friend (legacy)' }]); + return; + } + + // 2026-era leg: the auto-negotiating client reaches 2026-07-28 via + // server/discover — never initialize — and tools/call is served with the + // per-request envelope (the modern factory leg answers, not the slot). + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + const requestBodies = () => (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? ''); + // The "(never initialize)" clause of the requirement, asserted on the + // recorded wire traffic: no request body ever carried an initialize, + // and the negotiation rode server/discover. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); + expect(requestBodies().some(body => body.includes('server/discover'))).toBe(true); + const result = await client.callTool({ name: 'greet', arguments: { name: 'new friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); + // ...and still no initialize anywhere on the wire after the tool call — + // the whole conversation rode the modern handshake. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); +}); + +verifies('typescript:hosting:entry:pin-negotiation', async ({ transport }: TestArgs) => { + // Strict endpoint (no legacy slot — the entryModern arm default): the pinned client never needs one. + const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await using wired = await wire(transport, greetFactory, client); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + const requestBodies = () => (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? ''); + // No initialize was ever put on the wire; the first request is the discover probe. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); + expect(requestBodies()[0]).toContain('server/discover'); + + const result = await client.callTool({ name: 'greet', arguments: { name: 'pinned' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello pinned (modern)' }]); + // The tool call rode the per-request envelope on the wire... + const callBody = requestBodies().find(body => body.includes('"tools/call"')); + expect(callBody).toBeDefined(); + expect(callBody).toContain(PROTOCOL_VERSION_META_KEY); + // ...and still no initialize anywhere on the wire after the tool call. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); +}); + +verifies('typescript:hosting:entry:strict-rejects-legacy', async ({ transport }: TestArgs) => { + // legacy omitted → modern-only strict (the entryModern arm default): no silent 2025 serving. + const modernClient = new Client({ name: 'strict-modern-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await using wired = await wire(transport, greetFactory, modernClient); + + // The documented strict cell over plain HTTP: a 2025-shaped initialize is + // answered with the unsupported-protocol-version error naming the + // supported modern revisions (the numeric code is not pinned here). + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: LEGACY, capabilities: {}, clientInfo: { name: 'plain-2025-client', version: '1.0.0' } } + }) + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; message: string; data?: { supported?: string[] } } }; + expect(body.error.message).toMatch(/unsupported protocol version/i); + expect(body.error.data?.supported).toContain(MODERN); + + // The plain SDK client sees the same rejection at connect time. + const plainClient = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + try { + await expect(plainClient.connect(new StreamableHTTPClientTransport(wired.url!, { fetch: wired.fetch }))).rejects.toThrow( + /Unsupported protocol version|400/ + ); + } finally { + await plainClient.close().catch(() => {}); + } +}); + +verifies('typescript:hosting:entry:notification-202', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'notify-client', version: '1.0.0' }); + await using wired = await wire(transport, greetFactory, client, { entry: { legacy: 'stateless' } }); + + // 2025 leg: an envelope-less notification rides the legacy stateless slot. + // 2026 leg: the notification carries the per-request envelope and a method + // the 2026-07-28 registry defines. + const notification = + transport === 'entryStateless' + ? { jsonrpc: '2.0', method: 'notifications/initialized' } + : { + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: 'never-issued', + reason: 'probe', + _meta: modernEnvelopeMeta({ name: 'notify-client', version: '1.0.0' }) + } + }; + + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(notification) + }); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); +}); diff --git a/test/e2e/scenarios/stdio-dual-era.test.ts b/test/e2e/scenarios/stdio-dual-era.test.ts new file mode 100644 index 0000000000..46a2406ea1 --- /dev/null +++ b/test/e2e/scenarios/stdio-dual-era.test.ts @@ -0,0 +1,88 @@ +/** + * Self-contained test bodies for dual-era stdio serving. + * + * Like the other transport:stdio scenarios these do not use `wire()`: each + * body spawns the dual-era fixture server in + * `fixtures/dual-era-stdio-server.ts` (eraSupport: 'dual-era', unchanged + * StdioServerTransport) as a real child process via {@link StdioClientTransport}. + * The matrix `transport` arg is ignored (the requirement lists + * `transports: ['stdio']`); the spec-version axis selects which client drives + * the cell — a plain 2025 client over `initialize`, or the auto-negotiating + * client reaching 2026-07-28 over `server/discover` on the same kind of pipe. + */ + +import { fileURLToPath } from 'node:url'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Absolute path to the runnable dual-era fixture server (executed with tsx). */ +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/dual-era-stdio-server.ts', import.meta.url)); + +/** E2E package root — spawn cwd so node/tsx resolve the local toolchain and workspace packages. */ +const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url)); + +const MODERN = '2026-07-28'; + +verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion }: TestArgs) => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', FIXTURE_PATH], + cwd: E2E_ROOT + }); + + if (protocolVersion === '2025-11-25') { + // Legacy leg: a plain 2025 client is served via initialize, exactly as + // against an undeclared server. + const client = new Client({ name: 'plain-2025-client', version: '0' }); + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); + const result = await client.callTool({ name: 'echo', arguments: { text: 'legacy leg' } }); + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: 'text', text: 'legacy leg' }]); + } finally { + await client.close(); + await transport.close(); + } + return; + } + + // Modern leg: the auto-negotiating client reaches 2026-07-28 via + // server/discover on the pipe (no initialize is ever written) and + // tools/call round-trips with the per-request envelope. + const sentMethods: string[] = []; + const originalSend = transport.send.bind(transport); + transport.send = async message => { + if ('method' in message) sentMethods.push(message.method); + return originalSend(message); + }; + + const client = new Client({ name: 'auto-client', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); + expect(sentMethods).not.toContain('initialize'); + expect(sentMethods[0]).toBe('server/discover'); + + const envelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'auto-client', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + const result = (await client.request({ + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope } + })) as CallToolResult; + expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); + } finally { + await client.close(); + await transport.close(); + } +}); diff --git a/test/e2e/types.ts b/test/e2e/types.ts index d10ab18fca..8887f60322 100644 --- a/test/e2e/types.ts +++ b/test/e2e/types.ts @@ -2,9 +2,28 @@ * Shared types for the e2e suite. */ -export const ALL_TRANSPORTS = ['inMemory', 'stdio', 'streamableHttp', 'streamableHttpStateless', 'sse'] as const; +export const ALL_TRANSPORTS = [ + 'inMemory', + 'stdio', + 'streamableHttp', + 'streamableHttpStateless', + 'sse', + 'entryStateless', + 'entryModern' +] as const; export type Transport = (typeof ALL_TRANSPORTS)[number]; +/** + * The createMcpHandler entry arms: the dual-era HTTP entry hosted in process + * (injected fetch → `handler.fetch`), one arm per slot. `entryStateless` serves + * a plain 2025-era client through the entry's `legacy: 'stateless'` slot; + * `entryModern` serves a client that negotiates the 2026-07-28 revision through + * the entry's modern (per-request envelope) path. Each arm is era-fixed, so it + * registers cells on exactly one spec-version axis (see TRANSPORT_SPEC_VERSIONS). + */ +export const ENTRY_TRANSPORTS = ['entryStateless', 'entryModern'] as const satisfies readonly Transport[]; +export type EntryTransport = (typeof ENTRY_TRANSPORTS)[number]; + /** * Every spec version the manifest may reference — used for typing * `addedInSpecVersion` / `removedInSpecVersion` bounds and knownFailure @@ -16,6 +35,19 @@ export type SpecVersion = (typeof KNOWN_SPEC_VERSIONS)[number]; /** The spec versions cells are registered for (the active matrix axis). */ export const ALL_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const satisfies readonly SpecVersion[]; +/** + * Spec versions a transport arm can serve. Transports without an entry serve + * every spec version on the active axis; the entry arms are era-fixed (the + * `legacy: 'stateless'` slot serves only 2025-era traffic, the modern path + * serves only the 2026-07-28 revision), so each registers cells on exactly one + * axis. `verifies()` intersects this with a requirement's own spec-version + * bounds when forming cells. + */ +export const TRANSPORT_SPEC_VERSIONS: Partial> = { + entryStateless: ['2025-11-25'], + entryModern: ['2026-07-28'] +}; + /** * Arguments every test body receives. Expand with new matrix axes here so * test signatures don't churn — bodies destructure only what they use. @@ -32,6 +64,57 @@ export interface KnownFailure { note: string; } +/** + * Machine-readable reasons a requirement is excluded from the createMcpHandler + * entry arms. The exclusion list doubles as the acceptance checklist for the + * entry features that have not landed yet: when one of them lands, its + * reason's entries are the cells to re-admit. (Requirement families that the + * per-request entry structurally cannot serve at all — server→client requests, + * sessions/resumability, standalone GET streams, subscriptions — are already + * expressed through their existing `transports` restrictions and never reach + * the entry arms, so they need no annotation here.) + * + * - `requires-session` — needs a persistent connected server instance (or + * connection-level message delivery beyond one request/response exchange); + * the entry's modern path serves every request with a fresh instance. + * - `method-not-in-modern-registry` — drives a method the 2026-07-28 registry + * deletes (ping, logging/setLevel, resources/subscribe, + * notifications/roots/list_changed, …); meaningful only for `entryModern`. + * - `asserts-legacy-handshake` — asserts initialize/initialized handshake or + * initialize-based version-negotiation mechanics; the modern path negotiates + * via server/discover and never sends initialize, so the body would assert + * vacuously or fail. Meaningful only for `entryModern`. + * - `legacy-only-vocabulary` — asserts wire vocabulary or advertisement flags + * the 2026-07-28 surface deliberately deletes or omits (tools[].execution, + * listChanged/subscribe capability flags on server/discover). Meaningful + * only for `entryModern`. + * - `modern-error-surface` — asserts the 2025-era client-facing error surface + * (ProtocolError with the wire code) for dispatch-window errors; on the + * modern per-request path those errors ride mapped HTTP statuses and the + * client currently surfaces them as SdkHttpError (see the coverage report's + * GAPS FOUND). Meaningful only for `entryModern`. + * - `drives-transport-directly` — the body builds and drives its own transport + * or hosting instead of the wired pair, so an entry cell would duplicate an + * existing cell without exercising the entry. + */ +export const ENTRY_EXCLUSION_REASONS = [ + 'requires-session', + 'method-not-in-modern-registry', + 'asserts-legacy-handshake', + 'legacy-only-vocabulary', + 'modern-error-surface', + 'drives-transport-directly' +] as const; +export type EntryExclusionReason = (typeof ENTRY_EXCLUSION_REASONS)[number]; + +export interface EntryExclusion { + /** The entry arm excluded; omit to exclude both arms. */ + arm?: EntryTransport; + reason: EntryExclusionReason; + /** Optional elaboration beyond the machine-readable reason. */ + note?: string; +} + export interface Requirement { source: string; behavior: string; @@ -39,6 +122,15 @@ export interface Requirement { /** Free-form rationale for how the entry is set up (e.g. why certain transports are excluded). */ note?: string; + /** + * Exclusions from the createMcpHandler entry arms (`entryStateless` / + * `entryModern`), each with a machine-readable reason. Only meaningful when + * the requirement's transports would otherwise include the targeted arm + * (the default `ALL_TRANSPORTS` does); an explicit `transports` list that + * already omits the entry arms needs no annotation here. + */ + entryExclusions?: readonly EntryExclusion[]; + /** First / last spec versions a requirement applies to; changed behaviors are sibling entries linked via `supersedes`/`supersededBy`. */ addedInSpecVersion?: SpecVersion; removedInSpecVersion?: SpecVersion; From 5a4677f7cd0ce4aa7d63bf6d9f7a8ee315ee34fd Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:53:05 +0100 Subject: [PATCH 19/37] test(conformance): arm the fixtures for 2026-07-28 serving and refresh the expected-failures baseline (#2310) --- .github/workflows/conformance.yml | 2 + .../test/client/probeClassifier.test.ts | 2 +- pnpm-lock.yaml | 10 +- .../expected-failures.2026-07-28.yaml | 106 ++++++++++++++++++ test/conformance/expected-failures.yaml | 24 +--- test/conformance/package.json | 4 +- test/conformance/src/everythingClient.ts | 91 +++++++++++++++ test/conformance/src/everythingServer.ts | 36 +++++- 8 files changed, 248 insertions(+), 27 deletions(-) create mode 100644 test/conformance/expected-failures.2026-07-28.yaml diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 049b1e8fa0..dd01a74c10 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -30,6 +30,7 @@ jobs: - run: pnpm install - run: pnpm run build:all - run: pnpm run test:conformance:client:all + - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:2026 server-conformance: runs-on: ubuntu-latest @@ -48,3 +49,4 @@ jobs: - run: pnpm run build:all - run: pnpm run test:conformance:server - run: pnpm run test:conformance:server:draft + - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:2026 diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index 5318d30b5e..f442f65fb0 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -234,7 +234,7 @@ describe('row: plain-text/unparseable 400, code 0, empty body, 406, any unrecogn }); describe('row: -32001 / -32003 are NEVER probe-recognized → fall into unrecognized → legacy', () => { - test('-32001 (session-404 overload on deployed servers; ladder cell underived pending conformance #336)', () => { + test('-32001 (session-404 overload on deployed servers; the spec-assigned HeaderMismatch code is still never probe evidence)', () => { expect(classify({ kind: 'rpc-error', code: -32_001, message: 'Session not found' })).toEqual({ kind: 'legacy' }); expect(classify({ kind: 'http-error', status: 404, body: httpErrorBody(-32_001, 'Session not found') })).toEqual({ kind: 'legacy' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ffd38d3dd..483ebc939c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1099,8 +1099,8 @@ importers: specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.3 - version: 0.2.0-alpha.3(@cfworker/json-schema@4.1.1) + specifier: 0.2.0-alpha.4 + version: 0.2.0-alpha.4(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core': specifier: workspace:^ version: link:../../packages/core @@ -2111,8 +2111,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/conformance@0.2.0-alpha.3': - resolution: {integrity: sha512-YjdEKaKWswkJtRl0G3RmZCfljkAct3je834sqGHgasGeU2eUp7sb+6sJL0uNEaAY3XXWYumN/mjr6aPZbnbJMA==} + '@modelcontextprotocol/conformance@0.2.0-alpha.4': + resolution: {integrity: sha512-WAz/Q+Fmr2XFcytLkmbNAJvUi0vCciNLQbjkHnaUUSyPcqQZEVNfsLECZWhN8hRS8oGpGDl9OLR9yBtzyGIY2Q==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -6001,7 +6001,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/conformance@0.2.0-alpha.3(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/conformance@0.2.0-alpha.4(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@octokit/rest': 22.0.1 diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml new file mode 100644 index 0000000000..21792ec3a3 --- /dev/null +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -0,0 +1,106 @@ +# Expected failures for the carried-forward x 2026-07-28 legs +# (`test:conformance:client:2026` and `test:conformance:server:2026`, both +# `--suite all --spec-version 2026-07-28`). +# +# This baseline is separate from expected-failures.yaml because entries are +# keyed by scenario name only: a scenario that passes at its default version +# in the 2025 legs but fails when forced to 2026-07-28 (or vice versa) cannot +# be expressed in a shared file (the passing leg would flag the entry as +# stale). Like expected-failures.yaml, this single file covers both +# directions: the client 2026 leg reads the `client:` section and the server +# 2026 leg reads the `server:` section. Both burn down independently of the +# 2025 legs. +# +# Baseline established against the published @modelcontextprotocol/conformance +# release pinned in package.json. Newer conformance releases are adopted by +# deliberately bumping the pin and reconciling this file in the same change. +# +# Entries are grouped by what unblocks them. As each gap closes the +# corresponding scenarios start passing and MUST be removed from this list +# (the runner fails on stale entries), so the baseline burns down per +# milestone. + +client: + # --- SEP-837 (application_type during DCR) --- + # The sep-837-application-type-present check only fires on draft-version + # runs; the client omits application_type during Dynamic Client + # Registration, so every auth scenario that reaches DCR fails it on this + # leg (the same scenarios pass at their default version in the 2025 legs). + - auth/metadata-default + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/scope-from-www-authenticate + - auth/scope-from-scopes-supported + - auth/scope-omitted-when-undefined + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-post + - auth/token-endpoint-auth-none + - auth/offline-access-not-supported + + # --- Auth scenarios cut short by the 2026 connection lifecycle --- + # The fixture's auth flow drives the 2025 stateful lifecycle; the + # 2026-mode mock rejects the MCP POST (-32001, missing + # MCP-Protocol-Version header) before the scope-escalation behaviour these + # scenarios measure, so no authorization requests are observed. Unblocks + # when the auth fixture flow speaks the 2026 per-request lifecycle. + - auth/scope-step-up + - auth/scope-retry-limit + + # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- + # SEP-2322 (multi-round-trip requests): client does not echo requestState / + # handle IncompleteResult yet. + - sep-2322-client-request-state + # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. + - http-custom-headers + - http-invalid-tool-headers + # SEP-2106 (JSON Schema $ref handling): no fixture handler for the scenario yet. + - json-schema-ref-no-deref + # SEP-2468 (authorization response iss parameter): not implemented in the client. + - auth/iss-supported + - auth/iss-not-advertised + - auth/iss-supported-missing + - auth/iss-wrong-issuer + - auth/iss-unexpected + - auth/iss-normalized + - auth/metadata-issuer-mismatch + # SEP-2352 (authorization server migration): client does not re-register + # when PRM authorization_servers changes. + - auth/authorization-server-migration + +server: + # --- Carried-forward scenarios (also run by the 2025 legs) --- + # Pre-existing fixture/baseline bug: the fixture tool's schema is a plain + # Zod object with none of the JSON Schema 2020-12 keywords the scenario + # checks; it fails identically at 2025 in `--suite all` (not a 2026-path + # regression). + - json-schema-2020-12 + # SEP-2164: server returns -32002 without the requested URI in error.data + # (WARNING-only; the expected-failures evaluator counts WARNINGs as + # failures). Same failure as in the 2025 baseline. + - sep-2164-resource-not-found + + # --- Draft scenarios (same failures and reasons as the `--suite draft` leg) --- + # SEP-2243 (HTTP header standardization): the reject cells the SDK does + # answer now use -32001 (HeaderMismatch), but missing-header enforcement + # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, + # so those reject cells are still accepted with 200. + - http-header-validation + # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented + # in the SDK, so the fixture does not register the scenarios' diagnostic + # test_input_required_result_* tools. + - input-required-result-basic-elicitation + - input-required-result-basic-sampling + - input-required-result-basic-list-roots + - input-required-result-request-state + - input-required-result-multiple-input-requests + - input-required-result-multi-round + - input-required-result-non-tool-request + - input-required-result-result-type + - input-required-result-tampered-state + - input-required-result-capability-check + # SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, + # ignore unrecognized inputResponses keys): WARNING-only, but the + # expected-failures evaluator counts WARNINGs as failures. + - input-required-result-missing-input-response + - input-required-result-ignore-extra-params diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 486df89058..b22573d3f8 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,13 +2,9 @@ # CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.3). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.4). Newer conformance releases # are adopted by deliberately bumping the package.json pin and reconciling -# this file in the same change. 0.2.0-alpha.3 fixes the draft wire version -# (2026-07-28). Several auth scenarios in this baseline (auth/iss-*, -# auth/authorization-server-migration, auth/enterprise-managed-authorization) -# are still not shipped in the published release — the runner reports them -# unknown/failed; their entries below cover them either way. +# this file in the same change. # # NOTE: the SDK's modern-path rejection codes are aligned with what this # referee asserts: header/body mismatches answer -32001 (HeaderMismatch) and a @@ -22,9 +18,6 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2575 (request metadata / _meta envelope): client does not populate the - # _meta envelope or the MCP-Protocol-Version header semantics yet. - - request-metadata # SEP-2322 (multi-round-trip requests): client does not echo requestState / # handle IncompleteResult yet. - sep-2322-client-request-state @@ -59,12 +52,9 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) --- - # SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode, - # _meta-derived capabilities, error-code mappings, or server/discover yet. - - server-stateless - # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented; - # most scenarios currently fail early with "Session ID required" because the - # fixture only runs in stateful mode. + # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented + # in the SDK, so the fixture does not register the scenarios' diagnostic + # test_input_required_result_* tools. - input-required-result-basic-elicitation - input-required-result-basic-sampling - input-required-result-basic-list-roots @@ -75,15 +65,11 @@ server: - input-required-result-result-type - input-required-result-tampered-state - input-required-result-capability-check - # SEP-2549 (caching): no ttlMs/cacheScope support; scenario also hits the - # stateful-mode "Session ID required" error. - - caching # SEP-2243 (HTTP header standardization): the reject cells the SDK does # answer now use -32001 (HeaderMismatch), but missing-header enforcement # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, # so those reject cells are still accepted with 200. - http-header-validation - - http-custom-header-server-validation # WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level # WARNINGs, but the expected-failures evaluator counts WARNINGs as failures. # SEP-2164: server returns -32002 without the requested URI in error.data. diff --git a/test/conformance/package.json b/test/conformance/package.json index 7a1154b8ed..96becacab1 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -30,15 +30,17 @@ "client": "tsx scripts/cli.ts client", "test:conformance:client": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite core --expected-failures ./expected-failures.yaml", "test:conformance:client:all": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite all --expected-failures ./expected-failures.yaml", + "test:conformance:client:2026": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite all --spec-version 2026-07-28 --expected-failures ./expected-failures.2026-07-28.yaml", "test:conformance:client:run": "node --import tsx ./src/everythingClient.ts", "test:conformance:server": "scripts/run-server-conformance.sh --expected-failures ./expected-failures.yaml", "test:conformance:server:draft": "scripts/run-server-conformance.sh --suite draft --expected-failures ./expected-failures.yaml", "test:conformance:server:all": "scripts/run-server-conformance.sh --suite all --expected-failures ./expected-failures.yaml", + "test:conformance:server:2026": "scripts/run-server-conformance.sh --suite all --spec-version 2026-07-28 --expected-failures ./expected-failures.2026-07-28.yaml", "test:conformance:server:run": "node --import tsx ./src/everythingServer.ts", "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.3", + "@modelcontextprotocol/conformance": "0.2.0-alpha.4", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core": "workspace:^", diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 05103eb26d..e58f5558c3 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -14,9 +14,12 @@ import { Client, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, ClientCredentialsProvider, CrossAppAccessProvider, PrivateKeyJwtProvider, + PROTOCOL_VERSION_META_KEY, requestJwtAuthorizationGrant, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; @@ -96,6 +99,38 @@ function registerScenarios(names: string[], handler: ScenarioHandler): void { } } +// ============================================================================ +// 2026-07-28 (modern era) helpers +// ============================================================================ + +/** + * Spec versions whose wire lifecycle is the 2026-07-28 per-request envelope + * (no `initialize` handshake). The conformance runner passes the resolved + * spec version of the current scenario run via the + * MCP_CONFORMANCE_PROTOCOL_VERSION environment variable; when it names a + * modern version, version-spanning scenarios (e.g. tools_call) must speak the + * modern lifecycle instead of the 2025 stateful one. + */ +const MODERN_SPEC_VERSIONS = new Set(['2026-07-28']); + +function isModernConformanceRun(): boolean { + const version = process.env.MCP_CONFORMANCE_PROTOCOL_VERSION; + return version !== undefined && MODERN_SPEC_VERSIONS.has(version); +} + +/** + * The per-request `_meta` envelope every 2026-era request carries on the wire. + * Automatic envelope emission is not implemented in the client yet (it is a + * client-side follow-up), so modern-era requests attach it explicitly. + */ +function modernEnvelope(clientInfo: { name: string; version: string }, capabilities: object, protocolVersion: string | undefined) { + return { + [PROTOCOL_VERSION_META_KEY]: protocolVersion ?? '2026-07-28', + [CLIENT_INFO_META_KEY]: clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: capabilities + }; +} + // ============================================================================ // Basic scenarios (initialize, tools_call) // ============================================================================ @@ -117,6 +152,10 @@ async function runBasicClient(serverUrl: string): Promise { // tools_call scenario needs to actually call a tool async function runToolsCallClient(serverUrl: string): Promise { + if (isModernConformanceRun()) { + return runToolsCallModernClient(serverUrl); + } + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); @@ -141,8 +180,60 @@ async function runToolsCallClient(serverUrl: string): Promise { logger.debug('Connection closed successfully'); } +// tools_call under a 2026-07-28 run: negotiate the modern era via +// server/discover (versionNegotiation), then drive the same tool flow with +// the per-request _meta envelope attached to every request. +async function runToolsCallModernClient(serverUrl: string): Promise { + const clientInfo = { name: 'test-client', version: '1.0.0' }; + const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); + + const envelope = modernEnvelope(clientInfo, {}, client.getNegotiatedProtocolVersion()); + const tools = await client.request({ method: 'tools/list', params: { _meta: envelope } }); + logger.debug('Successfully listed tools'); + + // Call the add_numbers tool + const addTool = tools.tools.find(t => t.name === 'add_numbers'); + if (addTool) { + const result = await client.request({ + method: 'tools/call', + params: { name: 'add_numbers', arguments: { a: 5, b: 3 }, _meta: envelope } + }); + logger.debug('Tool call result:', JSON.stringify(result, null, 2)); + } + + await client.close(); + logger.debug('Connection closed successfully'); +} + +// request-metadata scenario (SEP-2575): every request must carry the +// MCP-Protocol-Version header and the per-request _meta envelope, and the +// client must retry with a supported version when its first choice is +// rejected with -32004. The version-negotiation probe (server/discover plus +// the corrective continuation) is exactly that mechanism. +async function runRequestMetadataClient(serverUrl: string): Promise { + const clientInfo = { name: 'test-client', version: '1.0.0' }; + const client = new Client(clientInfo, { + capabilities: { roots: { listChanged: true }, sampling: {}, elicitation: {} }, + versionNegotiation: { mode: 'auto' } + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); + + await client.close(); + logger.debug('Connection closed successfully'); +} + registerScenario('initialize', runBasicClient); registerScenario('tools_call', runToolsCallClient); +registerScenario('request-metadata', runRequestMetadataClient); // ============================================================================ // Auth scenarios - well-behaved client diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 387054f0b1..16c7d49be1 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto'; import { localhostHostValidation } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, EventId, EventStore, GetPromptResult, ReadResourceResult, StreamId } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { classifyInboundRequest, createMcpHandler, isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -872,6 +872,23 @@ function createMcpServer() { return mcpServer; } +// ===== 2026-07-28 (MODERN ERA) SERVING ===== + +// Modern-era traffic — requests claiming the per-request `_meta` envelope +// mechanism (SEP-2575), including `server/discover` and malformed variants of +// the claim — is served through `createMcpHandler`, backed by the same +// `createMcpServer()` fixture definition the 2025 sessions use. Legacy traffic +// never reaches this handler (see the routing in the POST handler below), so +// the 2025 stateful session path is unchanged. +const modernHandler = createMcpHandler(() => createMcpServer(), { + onerror: error => console.error('Modern-era MCP handler error:', error) +}); + +/** Normalize a possibly-repeated HTTP header to its first value. */ +function headerValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + // ===== EXPRESS APP ===== const app = express(); @@ -894,6 +911,23 @@ app.post('/mcp', async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; try { + // 2026-07-28 (modern era) traffic: anything claiming the per-request + // envelope mechanism — including malformed claims, which must get the + // modern validation-ladder errors rather than the 2025 session errors — + // is served by the createMcpHandler entry. Legacy-classified requests + // (initialize, no-claim traffic, batches, posted responses) fall + // through to the stateful 2025 session path below, untouched. + const inbound = classifyInboundRequest({ + httpMethod: req.method, + protocolVersionHeader: headerValue(req.headers['mcp-protocol-version']), + mcpMethodHeader: headerValue(req.headers['mcp-method']), + body: req.body + }); + if (inbound.kind !== 'legacy') { + await modernHandler.node(req, res, req.body); + return; + } + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { From a7164811465b9a5fc83eea4e421a2ac8aba30b61 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:05:57 +0100 Subject: [PATCH 20/37] test(e2e): audit transport-restricted requirements for the entry arms; admit two and harden entryModern (#2312) --- test/e2e/helpers/index.ts | 27 +++++++++++++++++++++++---- test/e2e/requirements.ts | 8 ++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index afd70b38a8..4a5218b5f5 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -164,6 +164,7 @@ export async function wire( clientTx = attachModernEnvelope(clientTx); } await client.connect(sniffTransport(clientTx, 'client', sniff)); + if (transport === 'entryModern') assertModernNegotiation(client); return { fetch, url, @@ -351,15 +352,33 @@ function pinModernNegotiation(client: Client): void { internals._versionNegotiation ??= { mode: { pin: MODERN_REVISION } }; } +/** + * Fail fast if an entryModern connection did not actually negotiate the + * 2026-07-28 revision. Every cell on the arm asserts modern-path behavior, so + * a broken negotiation pin (or a regression in the discover negotiation) would + * otherwise surface as hundreds of unrelated downstream assertion failures; + * this turns it into one attributable arm-level error right after connect. + */ +function assertModernNegotiation(client: Client): void { + const negotiated = client.getNegotiatedProtocolVersion(); + if (negotiated !== MODERN_REVISION) { + throw new Error( + `entryModern arm: expected the connection to negotiate protocol version ${MODERN_REVISION}, but it negotiated ${negotiated ?? 'no version'}` + ); + } +} + /** * The per-request `_meta` envelope stop-gap for the entryModern arm: the * negotiating client only attaches the envelope to its `server/discover` probe * today (automatic per-request emission is a client-side follow-up), so the * harness re-attaches the same envelope to every later request and notification * the scenario's typed calls put on the wire. The envelope is captured from the - * probe itself, so it always matches what the client actually claimed; messages - * that already carry a protocol-version claim (the probe, or a scenario's - * explicitly enveloped request) pass through untouched. + * latest enveloped message the client sent (normally the probe), so it always + * matches the most recent claim the client actually made — a connection that + * renegotiated would not keep stamping a stale version; messages that already + * carry a protocol-version claim (the probe, or a scenario's explicitly + * enveloped request) pass through untouched. * * Applied beneath the wire sniffer and `tapWire`, so recorded traffic shows the * messages exactly as the scenario sent them while the wire carries the @@ -374,7 +393,7 @@ function attachModernEnvelope(transport: T): T { const params = (message.params ?? {}) as { _meta?: Record }; const meta = params._meta; if (meta?.[PROTOCOL_VERSION_META_KEY] !== undefined) { - envelope ??= { + envelope = { [PROTOCOL_VERSION_META_KEY]: meta[PROTOCOL_VERSION_META_KEY], [CLIENT_INFO_META_KEY]: meta[CLIENT_INFO_META_KEY], [CLIENT_CAPABILITIES_META_KEY]: meta[CLIENT_CAPABILITIES_META_KEY] diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 567c55e023..71b2462633 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2145,11 +2145,11 @@ export const REQUIREMENTS: Record = { note: 'This is an HTTP-specific flow requiring session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'flow:tool-result:resource-link-follow': { - transports: STATEFUL_TRANSPORTS, + transports: [...STATEFUL_TRANSPORTS, 'entryStateless', 'entryModern'], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#resource-links', behavior: 'A resource_link returned by a tool call can be followed with resources/read on the linked URI to retrieve the referenced contents.', - note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' + note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these. The createMcpHandler entry arms are included: the body is plain client→server request/response (a tools/call, then a resources/read against the same statically-registered factory), so the per-request entry serves it on both eras.' }, 'flow:proxy:forward-tools-resources': { transports: ['inMemory', 'streamableHttp'], @@ -2367,8 +2367,8 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'A notification handler registered for a non-spec method with a params schema receives schema-validated custom notifications sent by the remote side.', - transports: STATEFUL_TRANSPORTS, - note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' + transports: [...STATEFUL_TRANSPORTS, 'entryStateless', 'entryModern'], + note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these. The createMcpHandler entry arms are included: the server→client heartbeats are emitted during the tools/call exchange (ctx.mcpReq.notify) and observed after it completes, and the client→server heartbeat is a plain notification handled by the per-request instance, so the entry arms serve the body on both eras.' }, 'typescript:method-string-handlers:result-type-inference': { entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], From 96bf12f3a6b30178d1bed5fec136be8b943c2720 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:16:23 +0100 Subject: [PATCH 21/37] =?UTF-8?q?feat(server):=20serveStdio=20=E2=80=94=20?= =?UTF-8?q?connection-pinned=20era=20serving=20for=20stdio;=20remove=20Ser?= =?UTF-8?q?verOptions.eraSupport=20(#2315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/server-era-support.md | 9 - .changeset/server-serve-stdio.md | 11 + docs/migration-SKILL.md | 11 +- docs/migration.md | 56 +- docs/server.md | 19 +- examples/client/src/dualEraStdioClient.ts | 7 +- examples/server/src/dualEraStdio.ts | 43 +- packages/client/src/client/client.ts | 2 +- .../test/client/probeFixtureCorpus.test.ts | 4 +- .../core/src/shared/inboundClassification.ts | 50 +- packages/core/src/shared/protocol.ts | 95 +-- packages/core/src/types/types.ts | 9 +- packages/core/src/wire/codec.ts | 14 +- .../shared/classifyInboundMessage.test.ts | 107 --- ...est.ts => protocolDropInboundHook.test.ts} | 135 +-- packages/server/src/index.ts | 6 +- .../server/src/server/createMcpHandler.ts | 32 +- packages/server/src/server/serveStdio.ts | 663 +++++++++++++++ packages/server/src/server/server.ts | 263 +----- packages/server/src/stdio.ts | 7 +- packages/server/test/server/discover.test.ts | 43 +- .../server/test/server/dualEraServing.test.ts | 384 --------- .../server/test/server/eraSupport.test.ts | 390 --------- .../test/server/legacyDefaultServing.test.ts | 113 +++ .../server/test/server/serveStdio.test.ts | 778 ++++++++++++++++++ test/e2e/fixtures/dual-era-stdio-server.ts | 35 +- test/e2e/requirements.ts | 6 +- test/e2e/scenarios/stdio-dual-era.test.ts | 24 +- .../test/__fixtures__/dualEraStdioServer.ts | 36 +- test/integration/test/client/client.test.ts | 12 +- .../test/client/discoverRoundtrip.test.ts | 42 +- .../test/server/dualEraStdio.test.ts | 120 ++- 32 files changed, 1950 insertions(+), 1576 deletions(-) delete mode 100644 .changeset/server-era-support.md create mode 100644 .changeset/server-serve-stdio.md delete mode 100644 packages/core/test/shared/classifyInboundMessage.test.ts rename packages/core/test/shared/{protocolClassifyInboundHook.test.ts => protocolDropInboundHook.test.ts} (57%) create mode 100644 packages/server/src/server/serveStdio.ts delete mode 100644 packages/server/test/server/dualEraServing.test.ts delete mode 100644 packages/server/test/server/eraSupport.test.ts create mode 100644 packages/server/test/server/legacyDefaultServing.test.ts create mode 100644 packages/server/test/server/serveStdio.test.ts diff --git a/.changeset/server-era-support.md b/.changeset/server-era-support.md deleted file mode 100644 index c805112f56..0000000000 --- a/.changeset/server-era-support.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@modelcontextprotocol/server': minor ---- - -Add `ServerOptions.eraSupport: 'legacy' | 'dual-era' | 'modern'`, the opt-in for serving the 2026-07-28 draft revision on long-lived connections such as stdio. The default is `'legacy'` and preserves today's behavior exactly: nothing 2026-era is registered or advertised, and 2025 -wire behavior is unchanged by the upgrade. `'dual-era'` serves both protocol eras on the same connection, selecting the era per message (`initialize`-negotiated 2025 traffic as before, per-request `_meta` envelope traffic — including `server/discover` — on the modern era), while -methods that exist in only one era stay invisible to the other. `'modern'` is strict 2026-only: requests without the envelope (including `initialize`) are answered with the unsupported-protocol-version error naming the supported revisions. A 2026-era revision in -`supportedProtocolVersions` now requires declaring `eraSupport` (`'dual-era'` or `'modern'`); on a default `'legacy'` instance it throws a `TypeError` at construction instead of silently installing the `server/discover` handler. On dual-era instances the deprecated -client-identity accessors keep their `initialize`-scoped semantics and are never backfilled from 2026-era requests; handlers read per-request identity from `ctx.mcpReq.envelope`. diff --git a/.changeset/server-serve-stdio.md b/.changeset/server-serve-stdio.md new file mode 100644 index 0000000000..d7331aaaeb --- /dev/null +++ b/.changeset/server-serve-stdio.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add `serveStdio(factory, options?)` (exported from `@modelcontextprotocol/server/stdio`), the connection-pinned stdio entry point for serving the 2026-07-28 draft revision on long-lived connections. The entry owns the transport and the era decision: the client's opening +exchange selects the era (a 2025 `initialize` handshake, 2026-07-28 per-request `_meta` envelope traffic, or a `server/discover` probe followed by either), and ONE instance from the factory is pinned to the connection and serves only that era — mirroring how +`createMcpHandler` classifies each HTTP request before constructing an instance. 2025-era openings are served by default; `legacy: 'reject'` answers them with the unsupported-protocol-version error naming the supported modern revisions instead. A `transport` option +accepts a bring-your-own `StdioServerTransport` (for example over a Unix domain socket); `onerror` reports out-of-band errors; the returned handle's `close()` tears the connection down. + +Removed: `ServerOptions.eraSupport` (introduced in an earlier 2.0 alpha, never in a stable release). A hand-constructed `Server`/`McpServer` serves only the 2025-era protocol it was written for; serving the 2026-07-28 revision always goes through a serving entry. Migrate +`new McpServer(info, { eraSupport: 'dual-era' })` + `connect(new StdioServerTransport())` to `serveStdio(() => new McpServer(info))`, and `eraSupport: 'modern'` to `serveStdio(factory, { legacy: 'reject' })`. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 311ae8d956..f8014412fb 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -552,11 +552,12 @@ These can require code changes: ### Server (stdio / long-lived connections) -- `ServerOptions.eraSupport?: 'legacy' | 'dual-era' | 'modern'` declares which protocol eras a hand-constructed `Server`/`McpServer` serves on its long-lived connection. Default `'legacy'` = today's behavior, byte-identical: do not add the option during a mechanical migration. -- Serving the 2026-07-28 draft revision on stdio is the explicit opt-in `new McpServer(info, { eraSupport: 'dual-era' })` with an unchanged `connect(new StdioServerTransport())`. `'modern'` is strict 2026-only (envelope-less requests, including `initialize`, get the - unsupported-protocol-version error). -- A 2026-era revision in `supportedProtocolVersions` now requires `eraSupport: 'dual-era' | 'modern'`; on a default (`'legacy'`) instance it throws a `TypeError` at construction (previously it silently installed the `server/discover` handler). -- On dual-era instances `getClientCapabilities()` / `getClientVersion()` / `getNegotiatedProtocolVersion()` keep `initialize`-scoped semantics and are never backfilled from 2026-era requests; handlers read per-request identity from `ctx.mcpReq.envelope`. +- A hand-constructed `Server`/`McpServer` connected to a `StdioServerTransport` serves only the 2025-era protocol it was written for: today's behavior, byte-identical — no change required during a mechanical migration. +- Serving the 2026-07-28 draft revision (or both eras) on stdio goes through the connection-pinned entry: `serveStdio(() => new McpServer(info, options))` from `@modelcontextprotocol/server/stdio`. The opening exchange selects the connection's era (2025 `initialize` vs + 2026 per-request envelope, with `server/discover` answered as a probe); one factory instance is pinned per connection. There is no per-instance option that makes a hand-constructed server serve the 2026 revision: move the v1 `server.connect(new StdioServerTransport())` + call into `serveStdio(() => buildServer())`. `serveStdio(factory, { legacy: 'reject' })` refuses 2025-era openings with the unsupported-protocol-version error. +- On 2026-pinned stdio connections `getClientCapabilities()` / `getClientVersion()` return `undefined` (no `initialize` ever runs there) and handlers read per-request identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned revision + (`2026-07-28`), as on instances served through `createMcpHandler`. 2025-pinned connections keep the `initialize`-scoped semantics for all three accessors. - A client whose connection negotiated a modern era drops inbound server→client JSON-RPC requests (the 2026 era has no such channel) instead of answering them; legacy-era connections are unchanged. ## 14. Runtime-Specific JSON Schema Validators (Enhancement) diff --git a/docs/migration.md b/docs/migration.md index 70ef7df0e8..1e9cbfdbde 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1025,9 +1025,9 @@ versionNegotiation: { } ``` -On the server side, a `Server`/`McpServer` serves `server/discover` (advertising only its modern revisions) when it declares modern-era support via the `eraSupport` option (see the stdio section below); servers constructed without it are byte-identical to before (they keep -answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the -next section; serving it on stdio (and other long-lived connections) is the `eraSupport` server option described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation probe +On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize` +handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and +other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation probe already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` @@ -1068,38 +1068,41 @@ The entry performs no Origin/Host validation (see the origin-validation middlewa request headers. Power users who want to compose routing themselves can use the exported `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around (`const { fetch } = handler`). -### Serving the 2026-07-28 draft revision on stdio: `eraSupport` +### Serving the 2026-07-28 draft revision on stdio: `serveStdio` -A hand-constructed `Server`/`McpServer` — the shape every stdio server has — now takes an `eraSupport` option declaring which protocol eras it serves on its long-lived connection. **The default is `'legacy'`: if you do nothing, your server keeps speaking exactly the -2025-era protocol it was written for** — the `initialize` handshake, the same wire bytes, no `server/discover`, nothing new advertised — and upgrading the SDK changes nothing about what it puts on the wire. - -Serving the 2026-07-28 draft revision is one explicit option; the transport stays unchanged: +The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to +that connection and serves only that era. ```typescript import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; -const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); -await server.connect(new StdioServerTransport()); +serveStdio(() => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once — the same factory serves both eras + return server; +}); ``` -What the values mean: +How the connection's era is decided: -- **`'legacy'` (default)** — today's behavior, unchanged: 2025-era serving negotiated via `initialize`. `server/discover` is not registered or advertised. Declaring a 2026-era revision in `supportedProtocolVersions` without changing `eraSupport` is now a construction-time - `TypeError` (previously it silently installed the discover handler) — serving the new revision is always an explicit declaration, never a side effect of a version list. -- **`'dual-era'`** — both eras on the same connection, selected per message: plain 2025 clients keep using `initialize` and are served exactly as before, while 2026-capable clients negotiate via `server/discover` on the same pipe and every request carrying the per-request - `_meta` envelope is served on the modern era. Methods that exist in only one era stay invisible to the other: a 2025-era client asking for a 2026-only method (such as `server/discover` without an envelope) gets the same plain `-32601` a 2025 server would send, and a - 2026-era request for a removed method (such as `logging/setLevel`) gets `-32601` too. -- **`'modern'`** — strict 2026-only: requests without the per-request envelope (including `initialize`) are answered with the unsupported-protocol-version error naming the supported revisions; legacy-era notifications are dropped. +- A plain 2025 client opens with the `initialize` handshake (or any request without the per-request `_meta` envelope): the connection is pinned to a 2025-era instance and served exactly as a hand-wired stdio server serves it today. Pass `legacy: 'reject'` to refuse + 2025-era openings instead — they are answered with the unsupported-protocol-version error naming the supported modern revisions, and there is no silent 2025 serving. +- A 2026-capable client opens with requests carrying the per-request `_meta` envelope: the connection is pinned to a 2026-era instance. +- A `server/discover` probe is answered (from an instance built with your factory, so the advertisement reflects your real server definition) without pinning the connection: the client either continues with enveloped modern requests — pinning the connection to the 2026 + era — or falls back to `initialize` when it shares no modern revision with the advertisement, in which case the probe instance is discarded and a fresh 2025-era instance serves the handshake. Once the modern era is pinned, a later `initialize` is rejected with the + unsupported-protocol-version error naming the supported revisions. -Declaring `'dual-era'` or `'modern'` automatically adds the SDK's supported modern revisions to `supportedProtocolVersions`, and `'modern'` serves only those: a strict instance's supported list (what `server/discover` advertises and version-mismatch errors name) is modern-only. +Because the entry may construct an instance for a probe that is later discarded (and `createMcpHandler` constructs one per request), factories should be cheap and side-effect-free. Bring your own transport with the `transport` option (for example a +`StdioServerTransport` over a Unix domain socket or TCP stream); by default the entry serves the current process's stdio. The returned handle's `close()` tears down the pinned instance and the transport. -Directionality follows the era of the traffic: the 2026-07-28 revision has no server→client JSON-RPC request channel, so a `'modern'` instance cannot emit `sampling`/`elicitation`/`roots` wire requests (they fail locally with a typed error), while a `'dual-era'` instance -can still send them to the 2025-era clients it serves via `initialize`. On a `'dual-era'` instance the same local typed error applies per request: a handler that is serving a 2026-era request cannot send server→client requests through its request context -(`ctx.mcpReq.send`, `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`) — only handlers serving 2025-era requests can. Symmetrically, a client whose connection negotiated a modern era drops inbound JSON-RPC requests instead of answering them. +Directionality follows the connection's era: the 2026-07-28 revision has no server→client JSON-RPC request channel, so handlers serving a 2026-pinned connection cannot emit `sampling`/`elicitation`/`roots` wire requests (they fail locally with a typed error), while a +2025-pinned connection keeps today's behavior. Symmetrically, a client whose connection negotiated a modern era drops inbound JSON-RPC requests instead of answering them. -Declaring `eraSupport: 'dual-era'` is also an assertion that your handlers are ready to serve modern-era requests (for example, that they read per-request client identity from `ctx.mcpReq.envelope` rather than the connection-scoped accessors — see the next section). A -future release may add per-handler era declarations as the basis for a safe automatic default; for now the connection-level `eraSupport` option is the whole opt-in surface. +**The v1 stdio pattern keeps working and stays 2025-only.** A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` — the way every v1 stdio server is written — still works and serves only the 2025-era protocol it was written for: upgrading +the SDK changes nothing about what it puts on the wire, and no per-instance option turns such a server into a 2026-era server. Serving the 2026-07-28 revision (or both eras) on stdio always goes through `serveStdio`. To migrate an existing v1 stdio server, move its +construction into the factory: replace `await server.connect(new StdioServerTransport())` with `serveStdio(() => buildServer())`, registering tools/resources/prompts inside the factory as before — and pass `{ legacy: 'reject' }` if 2025-era clients should be refused +instead of served. ### Cache fields and cache hints for cacheable 2026-07-28 results @@ -1137,8 +1140,9 @@ capabilities, and `ProtocolError.fromError` recognizes the code/data shape (reco handlers as `ctx.mcpReq.envelope`; instances serving that revision through `createMcpHandler` are backfilled per request, so existing code that calls the accessors keeps working on both eras. On 2025-era connections the accessors keep returning the `initialize`-scoped values, as before. -On a long-lived dual-era instance (`eraSupport: 'dual-era'`, e.g. a stdio server) the accessors are **not** backfilled from modern requests: 2025-era and 2026-era messages interleave on one connection, so instance-level backfill would race. There the accessors keep their -`initialize`-scoped semantics — they reflect what the legacy handshake negotiated (or `undefined` when none ran) — and handlers serving 2026-era requests read the per-request identity from `ctx.mcpReq.envelope`. +On a connection pinned to the 2026-07-28 era by `serveStdio` the identity accessors are **not** backfilled: the modern era carries client identity per request, so connection-scoped identity has nothing stable to report there. +`getClientCapabilities()` and `getClientVersion()` return `undefined` (no `initialize` handshake ever ran on such a connection) and handlers read the per-request identity from `ctx.mcpReq.envelope`. `getNegotiatedProtocolVersion()` reports the pinned revision +(`2026-07-28`) — the entry era-marks the instance when it binds it, so the accessor reports the same value as on instances serving that revision through `createMcpHandler`. On 2025-pinned connections the accessors keep their `initialize`-scoped semantics, as before. ### Origin validation middleware and default arming diff --git a/docs/server.md b/docs/server.md index 1b38ac5c83..53bd3e6051 100644 --- a/docs/server.md +++ b/docs/server.md @@ -64,17 +64,22 @@ await server.connect(transport); #### Serving the 2026-07-28 draft revision on stdio -By default a stdio server speaks the 2025-era protocol it was written for (`eraSupport: 'legacy'`): nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision is one explicit option — the transport stays unchanged: +A hand-constructed stdio server speaks the 2025-era protocol it was written for: nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision goes through the connection-pinned `serveStdio` entry, which mirrors `createMcpHandler` +for long-lived connections — the entry owns the transport and the era decision, and one instance from your factory serves the era the client opened the connection with: ```typescript -const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); -await server.connect(new StdioServerTransport()); +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +serveStdio(() => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once — the same factory serves both eras + return server; +}); ``` -With `eraSupport: 'dual-era'` the same long-lived connection serves both eras, selected per message: plain 2025 clients keep using `initialize` and are served exactly as before, while 2026-capable clients negotiate via `server/discover` and send each request with the -per-request `_meta` envelope. Methods that exist in only one era stay invisible to the other (a 2025-era client asking for a 2026-only method gets a plain `-32601`). `eraSupport: 'modern'` is strict 2026-only. On dual-era instances, read per-request client identity from -`ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for details). A runnable example lives at `examples/server/src/dualEraStdio.ts`, with a two-legged client at -`examples/client/src/dualEraStdioClient.ts`. +Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. Pass `legacy: 'reject'` to +refuse 2025-era openings with the unsupported-protocol-version error. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for +details). A runnable example lives at `examples/server/src/dualEraStdio.ts`, with a two-legged client at `examples/client/src/dualEraStdioClient.ts`. ## Server instructions diff --git a/examples/client/src/dualEraStdioClient.ts b/examples/client/src/dualEraStdioClient.ts index e58bdcdedd..a8b4a6e317 100644 --- a/examples/client/src/dualEraStdioClient.ts +++ b/examples/client/src/dualEraStdioClient.ts @@ -1,6 +1,7 @@ /** - * Drives the dual-era stdio server example (`examples/server/src/dualEraStdio.ts`) - * with both kinds of client over a real child-process pipe: + * Drives the dual-era stdio server example (`examples/server/src/dualEraStdio.ts`, + * a `serveStdio` server) with both kinds of client, each over its own real + * child-process pipe: * * 1. a plain 2025 client — the `initialize` handshake, served exactly as today; * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the @@ -65,4 +66,4 @@ async function modernLeg(): Promise { await legacyLeg(); await modernLeg(); -console.log('both legs served by the same dual-era stdio server.'); +console.log('both legs served by the same dual-era stdio server factory.'); diff --git a/examples/server/src/dualEraStdio.ts b/examples/server/src/dualEraStdio.ts index 28009e0bc9..4153c38aeb 100644 --- a/examples/server/src/dualEraStdio.ts +++ b/examples/server/src/dualEraStdio.ts @@ -1,30 +1,33 @@ /** - * Dual-era stdio serving with `eraSupport: 'dual-era'`: one server process, - * one long-lived pipe, both protocol eras. + * Dual-era stdio serving with `serveStdio`: one server process, both protocol + * eras, one factory. * - * The same construction backs both legs — nothing about the transport or the - * tool changes per era: + * The entry owns the era decision per connection: the client's opening + * exchange selects the era, one instance from the factory is pinned for the + * connection lifetime, and that instance serves only that era. * * - a plain 2025 client connects with the `initialize` handshake and is served - * exactly as today; - * - a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) negotiates - * the 2026-07-28 revision via `server/discover` on the same pipe and is - * served on the modern era, message by message. + * by a 2025-era instance exactly as today; + * - a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) probes with + * `server/discover`, negotiates the 2026-07-28 revision, and is served by a + * 2026-era instance — every request carrying the per-request `_meta` + * envelope. * - * Opting in is the single `eraSupport` option; the default (`'legacy'`) - * preserves today's behavior exactly. + * The same factory backs both: tools are defined once and served identically + * to either kind of client. * * Run with `tsx examples/server/src/dualEraStdio.ts` (or point any stdio MCP * client at it). `examples/client/src/dualEraStdioClient.ts` drives both legs - * against the built version of this file. + * against this file. */ import type { CallToolResult } from '@modelcontextprotocol/server'; import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; -// One construction for both legs: tools are defined once and served -// identically to 2025-era and 2026-era clients. +// One factory for both eras: tools are defined once and served identically to +// 2025-era and 2026-era clients. The entry constructs one instance per +// connection, for the era that connection's client opened with. const buildServer = () => { const server = new McpServer( { @@ -33,9 +36,7 @@ const buildServer = () => { }, { capabilities: { tools: {} }, - instructions: 'A small dual-era stdio demo server.', - // The one declared act: serve both protocol eras on this long-lived pipe. - eraSupport: 'dual-era' + instructions: 'A small dual-era stdio demo server.' } ); @@ -53,13 +54,13 @@ const buildServer = () => { return server; }; -const server = buildServer(); -// The transport is unchanged: dual-era support is purely a server-options declaration. -await server.connect(new StdioServerTransport()); +// The entry owns the stdio transport and the era decision; 2025-era clients +// are served by default (`legacy: 'serve'`). +const handle = serveStdio(buildServer); console.error('dual-era stdio server ready (serving 2025-era initialize and 2026-07-28 envelope traffic)'); const exit = async () => { - await server.close(); + await handle.close(); // eslint-disable-next-line unicorn/no-process-exit process.exit(0); }; diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 9ae2950719..0831efb13f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -288,7 +288,7 @@ export class Client extends Protocol { * (surfaced via `onerror`) rather than answered. Connections on a legacy * era — and all responses and notifications — keep today's dispatch path. */ - protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { + protected override _shouldDropInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { if ( this._negotiatedProtocolVersion !== undefined && isModernProtocolVersion(this._negotiatedProtocolVersion) && diff --git a/packages/client/test/client/probeFixtureCorpus.test.ts b/packages/client/test/client/probeFixtureCorpus.test.ts index 2e46b5faea..2d4c8a16f5 100644 --- a/packages/client/test/client/probeFixtureCorpus.test.ts +++ b/packages/client/test/client/probeFixtureCorpus.test.ts @@ -11,8 +11,8 @@ * version" literal and the 400/−32000 session-required body). Recognition * is a typed allowlist — codes and structured data — never message-text * sniffing. - * - the era predicate's per-message form is bound by - * `packages/core/test/shared/classifyInboundMessage.test.ts` (T11). + * - the server-side opening classification (the era a connection's first + * exchange selects) is bound by `packages/server/test/server/serveStdio.test.ts`. * * Probe RUNTIME (timeout/retry policy and the connect loop) is covered by the * negotiation engine suites; this corpus pins classification only, plus the diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 3dd85ad2ad..02e332236f 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -391,8 +391,12 @@ function classificationForClaim(claimedVersion: string | undefined): MessageClas * modern era then answers `initialize` exactly like any other method it does * not define (method-not-found). A malformed claim, or one naming a pre-2026 * revision, keeps the legacy-handshake routing unchanged. + * + * Exported on the core internal barrel for the stdio serving entry, which + * applies the same precedence rule to a connection's opening message; not + * public API. */ -function carriesValidModernEnvelopeClaim(params: unknown): boolean { +export function carriesValidModernEnvelopeClaim(params: unknown): boolean { if (!hasEnvelopeClaim(params)) { return false; } @@ -657,50 +661,6 @@ export function classifyInboundRequest(request: InboundHttpRequest): InboundClas ); } -/* ------------------------------------------------------------------------ * - * Per-message classification (long-lived channels) - * ------------------------------------------------------------------------ */ - -/** - * Classifies one inbound JSON-RPC message for a long-lived dual-era channel - * (stdio and other hand-wired transports with no HTTP edge): the body-primary - * predicate reduced to its per-message form — there is no header layer (the - * stdio transport carries all request metadata inline in the message body) - * and no HTTP method to route. - * - * - `initialize` is the legacy handshake by definition; the version it - * requested is carried as the classification's `revision`. - * - A message whose `params._meta` carries the reserved protocol-version key - * claims the per-request envelope mechanism and classifies into the era of - * the named revision. Envelope validity is enforced at dispatch by the era - * codec — a malformed envelope behind a present claim is a validation - * error, never a silent fall back to legacy handling. - * - A message without that claim — including one carrying only - * `progressToken` or other non-reserved `_meta` keys — is legacy-era - * traffic. - * - * Pure and total over requests and notifications; consumed by the - * protocol-layer classification consult for dual-era server instances. - */ -export function classifyInboundMessage(message: { method: string; params?: unknown }): MessageClassification { - if (message.method === 'initialize') { - const params = message.params; - const requestedVersion = - isPlainObject(params) && typeof params['protocolVersion'] === 'string' ? params['protocolVersion'] : undefined; - // The classification's `revision` names the wire revision the message - // is classified INTO, so it only carries the requested version when - // that version is itself a legacy one — an `initialize` requesting a - // modern revision is still the legacy handshake (it never negotiates - // a modern era) and stays a bare legacy classification. - const legacyRevision = requestedVersion !== undefined && !isModernProtocolVersion(requestedVersion) ? requestedVersion : undefined; - return { era: 'legacy', ...(legacyRevision !== undefined && { revision: legacyRevision }) }; - } - if (hasEnvelopeClaim(message.params)) { - return classificationForClaim(envelopeClaimVersion(message.params)); - } - return { era: 'legacy' }; -} - /* ------------------------------------------------------------------------ * * Modern-only (strict) mapping of legacy routes * ------------------------------------------------------------------------ */ diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d2335a6065..8feffae174 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -15,7 +15,6 @@ import type { JSONRPCResponse, JSONRPCResultResponse, LoggingLevel, - MessageClassification, MessageExtraInfo, Notification, NotificationMethod, @@ -490,24 +489,23 @@ export abstract class Protocol { protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; /** - * Classification consult for inbound messages whose transport did not - * classify them at the edge — long-lived dual-era channels such as stdio, - * where the protocol era is decided per message rather than per request - * at an HTTP edge. + * Drop consult for inbound messages whose transport did not classify them + * at the edge — long-lived channels such as stdio, where a role class may + * need to decline traffic the negotiated era has no answer for (the + * client-side inbound-request drop on modern-era connections: the + * 2026-07-28 era has no server→client request channel, and on stdio the + * client must never write JSON-RPC responses). * * Consulted ONLY when the transport supplied no - * {@linkcode MessageExtraInfo.classification}: an edge classification - * always wins and the hook is never reached for it. The returned - * classification populates the carrier; on an instance with no negotiated - * protocol version it also selects the wire era for this one message, - * while an instance bound to a negotiated version validates it exactly - * like an edge classification (a mismatch is the typed - * unsupported-protocol-version answer for requests, a drop for - * notifications). Returning `'drop'` discards the message without writing - * any response. The base implementation returns `undefined`: unclassified - * traffic keeps today's dispatch path unchanged. + * {@linkcode MessageExtraInfo.classification}: edge-classified traffic + * never reaches the hook. Returning `'drop'` discards the message without + * writing any response (requests are surfaced via `onerror`). The base + * implementation returns `undefined`: unclassified traffic keeps today's + * dispatch path unchanged. Era selection never happens here — era is + * instance state, owned by the serving entry that constructed and + * connected the instance. */ - protected _classifyInbound(_message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { + protected _shouldDropInbound(_message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { return undefined; } @@ -650,26 +648,15 @@ export abstract class Protocol { // Era is instance state: the negotiated protocol version selects the // codec for everything this connection receives (legacy until - // negotiated). An edge classification is never a per-message era - // switch — it is validated against the instance era below. - let codec = this._negotiatedWireCodec(); - - // Classification consult (only when the transport did not classify; - // an edge classification always wins and never reaches the hook). On - // an unbound instance the hook's classification selects the era for - // this one message (long-lived dual-era channels); a bound instance - // validates it below exactly like an edge classification. - if (extra?.classification === undefined) { - const consulted = this._classifyInbound(rawNotification); - if (consulted === 'drop') { - return; - } - if (consulted !== undefined) { - extra = { ...extra, classification: consulted }; - if (this._negotiatedProtocolVersion === undefined) { - codec = codecForVersion(classifiedWireEra(consulted)); - } - } + // negotiated). Classification is never a per-message era switch — an + // edge classification is validated against the instance era below. + const codec = this._negotiatedWireCodec(); + + // Drop consult (only when the transport did not classify; edge- + // classified traffic never reaches the hook): a role class may decline + // unclassified inbound traffic the negotiated era has no answer for. + if (extra?.classification === undefined && this._shouldDropInbound(rawNotification) === 'drop') { + return; } // Edge→instance handoff check: a classification that disagrees with @@ -720,29 +707,19 @@ export abstract class Protocol { // Era is instance state: the negotiated protocol version selects the // codec for everything this connection receives (legacy until - // negotiated). An edge classification (Q2; produced at the HTTP - // entry) is never a per-message era switch — it is validated against - // the instance era below. Hand-wired legacy transports never - // classify, so their behavior is untouched. - let codec = this._negotiatedWireCodec(); - - // Classification consult (only when the transport did not classify; - // an edge classification always wins and never reaches the hook). On - // an unbound instance the hook's classification selects the era for - // this one message (long-lived dual-era channels); a bound instance - // validates it below exactly like an edge classification. - if (extra?.classification === undefined) { - const consulted = this._classifyInbound(rawRequest); - if (consulted === 'drop') { - this._onerror(new Error(`Dropped inbound request '${rawRequest.method}': not servable on this connection's protocol era`)); - return; - } - if (consulted !== undefined) { - extra = { ...extra, classification: consulted }; - if (this._negotiatedProtocolVersion === undefined) { - codec = codecForVersion(classifiedWireEra(consulted)); - } - } + // negotiated). Classification (Q2; produced at the transport/entry + // edge — this layer only CONSUMES MessageExtraInfo.classification) is + // never a per-message era switch — it is validated against the + // instance era below. Hand-wired legacy transports never classify, so + // their behavior is untouched. + const codec = this._negotiatedWireCodec(); + + // Drop consult (only when the transport did not classify; edge- + // classified traffic never reaches the hook): a role class may decline + // unclassified inbound traffic the negotiated era has no answer for. + if (extra?.classification === undefined && this._shouldDropInbound(rawRequest) === 'drop') { + this._onerror(new Error(`Dropped inbound request '${rawRequest.method}': not servable on this connection's protocol era`)); + return; } // Capture the current transport at request time to ensure responses go to the correct client diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 92acc6a6ad..55b2bf7481 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -631,10 +631,7 @@ export type ListChangedHandlers = { * `Client`/`Server` instance); the protocol layer validates a classified * message against that instance era at dispatch — a mismatch is treated as * an entry/routing error, never a per-message era switch. Unclassified - * traffic is dispatched on the instance era unchanged, except on long-lived - * dual-era channels (e.g. a stdio server that declared dual-era support), - * where the protocol layer's own classification consult classifies each - * message and selects its era per message. + * traffic is dispatched on the instance era unchanged. */ export interface MessageClassification { /** @@ -662,9 +659,7 @@ export interface MessageExtraInfo { * Protocol-era classification of the message, when the transport * classified it at the edge. Validated by the protocol layer against the * instance's negotiated era at dispatch (the edge→instance handoff - * check); an edge classification never selects the era itself. When the - * transport did not classify, the protocol layer's classification consult - * may populate this carrier per message (long-lived dual-era channels). + * check); it does not select the era itself. */ classification?: MessageClassification; diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index 6ce2402cb8..d98d8e23f1 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -173,14 +173,12 @@ export function codecForVersion(version: string | undefined): WireCodec { } /** - * The wire era a classification names (Q2 — produced at the transport/entry - * edge or, for long-lived dual-era channels, by the protocol layer's own - * per-message classification consult). For edge classifications the dispatch - * funnel never resolves a codec FROM the classification: era is instance - * state, and the classified message is VALIDATED against it — a mismatch is - * an entry/routing error. Only an unbound dual-era instance selects the - * message's codec from its classification (per-message era). The exact - * `revision` wins over the coarse era flag when both are present. + * The wire era an edge classification names (Q2 — produced at the + * transport/entry edge; this layer only CONSUMES it). The dispatch funnel no + * longer resolves a codec FROM the classification: era is instance state, and + * a classified inbound message is VALIDATED against the instance era — a + * mismatch is an entry/routing error, never a per-message era switch. The + * exact `revision` wins over the coarse era flag when both are present. */ export function classifiedWireEra(classification: MessageClassification): WireEra { if (classification.revision !== undefined) return codecForVersion(classification.revision).era; diff --git a/packages/core/test/shared/classifyInboundMessage.test.ts b/packages/core/test/shared/classifyInboundMessage.test.ts deleted file mode 100644 index 1b86f690df..0000000000 --- a/packages/core/test/shared/classifyInboundMessage.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Per-message era predicate for long-lived dual-era channels - * (`classifyInboundMessage`) — the body-primary rule (Q2) in its stdio form, - * with the T11 sharpening: classification keys on the SPECIFIC reserved - * envelope key (`io.modelcontextprotocol/protocolVersion`), never on bare - * `_meta` presence. - */ -import { describe, expect, it } from 'vitest'; - -import { classifyInboundMessage } from '../../src/shared/inboundClassification.js'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - LOG_LEVEL_META_KEY, - PROTOCOL_VERSION_META_KEY -} from '../../src/types/index.js'; - -const MODERN = '2026-07-28'; - -const fullEnvelope = (version: string) => ({ - [PROTOCOL_VERSION_META_KEY]: version, - [CLIENT_INFO_META_KEY]: { name: 'fixture-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} -}); - -describe('classifyInboundMessage (per-message body-primary predicate)', () => { - it('classifies `initialize` as legacy and carries the requested version as the revision', () => { - const classification = classifyInboundMessage({ - method: 'initialize', - params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } } - }); - expect(classification).toEqual({ era: 'legacy', revision: '2025-06-18' }); - }); - - it('classifies `initialize` without a parsable protocolVersion as legacy with no revision', () => { - expect(classifyInboundMessage({ method: 'initialize', params: {} })).toEqual({ era: 'legacy' }); - expect(classifyInboundMessage({ method: 'initialize' })).toEqual({ era: 'legacy' }); - }); - - it('classifies `initialize` REQUESTING a modern revision as a bare legacy classification (initialize never negotiates a modern era)', () => { - const classification = classifyInboundMessage({ - method: 'initialize', - params: { protocolVersion: MODERN, capabilities: {}, clientInfo: { name: 'c', version: '1' } } - }); - expect(classification).toEqual({ era: 'legacy' }); - }); - - it('classifies a message carrying the reserved protocol-version envelope key as modern with the claimed revision', () => { - const classification = classifyInboundMessage({ - method: 'tools/list', - params: { _meta: fullEnvelope(MODERN) } - }); - expect(classification).toEqual({ era: 'modern', revision: MODERN }); - }); - - it('classifies an envelope claim naming a 2025-era revision as legacy with that revision', () => { - const classification = classifyInboundMessage({ - method: 'tools/list', - params: { _meta: { [PROTOCOL_VERSION_META_KEY]: '2025-06-18' } } - }); - expect(classification).toEqual({ era: 'legacy', revision: '2025-06-18' }); - }); - - it('classifies a claim with a non-string protocol-version value as a modern claim (validated at dispatch, never silently legacy)', () => { - const classification = classifyInboundMessage({ - method: 'tools/list', - params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } } - }); - expect(classification).toEqual({ era: 'modern' }); - }); - - it('T11: a legacy client carrying only `progressToken` in `_meta` classifies legacy — never bare `_meta` presence', () => { - const classification = classifyInboundMessage({ - method: 'tools/call', - params: { name: 'echo', arguments: {}, _meta: { progressToken: 7 } } - }); - expect(classification).toEqual({ era: 'legacy' }); - }); - - it('T11: other reserved envelope keys without the protocol-version key do NOT constitute a claim', () => { - const classification = classifyInboundMessage({ - method: 'tools/call', - params: { - name: 'echo', - arguments: {}, - _meta: { - [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, - [CLIENT_CAPABILITIES_META_KEY]: {}, - [LOG_LEVEL_META_KEY]: 'info' - } - } - }); - expect(classification).toEqual({ era: 'legacy' }); - }); - - it('classifies a claim-less request as legacy', () => { - expect(classifyInboundMessage({ method: 'tools/list', params: {} })).toEqual({ era: 'legacy' }); - expect(classifyInboundMessage({ method: 'ping' })).toEqual({ era: 'legacy' }); - }); - - it('classifies notifications by the same body-primary rule', () => { - expect(classifyInboundMessage({ method: 'notifications/cancelled', params: { requestId: 1 } })).toEqual({ era: 'legacy' }); - expect( - classifyInboundMessage({ method: 'notifications/cancelled', params: { requestId: 1, _meta: fullEnvelope(MODERN) } }) - ).toEqual({ era: 'modern', revision: MODERN }); - }); -}); diff --git a/packages/core/test/shared/protocolClassifyInboundHook.test.ts b/packages/core/test/shared/protocolDropInboundHook.test.ts similarity index 57% rename from packages/core/test/shared/protocolClassifyInboundHook.test.ts rename to packages/core/test/shared/protocolDropInboundHook.test.ts index 23bb7cf6ce..40b99452d8 100644 --- a/packages/core/test/shared/protocolClassifyInboundHook.test.ts +++ b/packages/core/test/shared/protocolDropInboundHook.test.ts @@ -1,67 +1,44 @@ /** - * The protocol-layer classification consult (`Protocol._classifyInbound`): + * The protocol-layer drop consult (`Protocol._shouldDropInbound`): * * - B-2 pin: when the transport supplied an edge classification, the hook is * NEVER consulted — the edge classification always wins. * - The base implementation returns `undefined`, so unclassified traffic on * a default instance keeps today's dispatch path byte-identically. - * - A hook classification populates the `MessageExtraInfo.classification` - * carrier and, on an UNBOUND instance (no negotiated protocol version), - * selects the wire era for that one message (per-message era on long-lived - * dual-era channels). On a BOUND instance it is validated exactly like an - * edge classification (mismatch ⇒ −32004 for requests, drop for - * notifications). - * - Returning `'drop'` discards the message without writing any response. + * - Returning `'drop'` discards the message without writing any response + * (requests are surfaced via `onerror`, notifications are silent). This is + * the seam the client uses to decline inbound requests on connections that + * negotiated a modern era. Era selection never happens here — era is + * instance state owned by the serving entry. */ import { describe, expect, it } from 'vitest'; -import * as z from 'zod/v4'; import type { BaseContext } from '../../src/shared/protocol.js'; -import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, - JSONRPCResultResponse, - MessageClassification, - MessageExtraInfo, - Result -} from '../../src/types/index.js'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - isJSONRPCErrorResponse, - isJSONRPCResultResponse, - PROTOCOL_VERSION_META_KEY + JSONRPCResultResponse } from '../../src/types/index.js'; +import { isJSONRPCResultResponse } from '../../src/types/index.js'; import { InMemoryTransport } from '../../src/util/inMemory.js'; -const MODERN = '2026-07-28'; - -const modernEnvelope = { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'hook-test-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} -}; - class HookedProtocol extends Protocol { /** Messages the hook was consulted for (in order). */ consulted: Array = []; /** What the hook answers; `undefined` keeps the base behavior. */ - verdict: ((message: JSONRPCRequest | JSONRPCNotification) => MessageClassification | 'drop' | undefined) | undefined; - /** The MessageExtraInfo handed to buildContext for the last dispatched request. */ - lastExtra: MessageExtraInfo | undefined; + verdict: ((message: JSONRPCRequest | JSONRPCNotification) => 'drop' | undefined) | undefined; protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} protected assertRequestHandlerCapability(): void {} - protected buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): BaseContext { - this.lastExtra = transportInfo; + protected buildContext(ctx: BaseContext): BaseContext { return ctx; } - protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { + protected override _shouldDropInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { this.consulted.push(message); return this.verdict?.(message); } @@ -92,7 +69,7 @@ async function wire>(protocol: T) { describe('B-2: an edge classification always wins', () => { it('never consults the hook for a message that already carries a classification', async () => { const protocol = new HookedProtocol(); - protocol.verdict = () => ({ era: 'modern', revision: MODERN }); + protocol.verdict = () => 'drop'; const { protocolTx, sent } = await wire(protocol); protocolTx.onmessage?.( @@ -122,7 +99,7 @@ describe('B-2: an edge classification always wins', () => { expect(protocol.consulted).toHaveLength(1); expect(protocol.consulted[0]).toMatchObject({ method: 'tools/list' }); - // `undefined` keeps today's path: no handler ⇒ −32601, no classification carrier. + // `undefined` keeps today's path: no handler ⇒ −32601. expect(sent).toHaveLength(1); expect((sent[0] as JSONRPCErrorResponse).error.code).toBe(-32_601); await protocol.close(); @@ -145,78 +122,19 @@ describe("base implementation (no override) keeps today's dispatch", () => { expect(JSON.stringify(response)).not.toContain('resultType'); await protocol.close(); }); -}); -describe('per-message era on an unbound instance (long-lived dual-era channels)', () => { - it('a hook classification of modern serves the message on the 2026 era: envelope honored, result stamped', async () => { + it('an undefined verdict from an overriding hook also keeps the handler path unchanged', async () => { const protocol = new HookedProtocol(); - protocol.verdict = message => (message.method === 'initialize' ? { era: 'legacy' } : { era: 'modern', revision: MODERN }); + protocol.verdict = () => undefined; protocol.setRequestHandler('tools/list', () => ({ tools: [] })); const { peerTx, sent } = await wire(protocol); - await peerTx.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: modernEnvelope } }); - await flush(); - - expect(sent).toHaveLength(1); - const response = sent[0] as JSONRPCResultResponse; - expect(isJSONRPCResultResponse(response)).toBe(true); - expect((response.result as { resultType?: string }).resultType).toBe('complete'); - // The carrier was populated and reached the handler context. - expect(protocol.lastExtra?.classification).toEqual({ era: 'modern', revision: MODERN }); - await protocol.close(); - }); - - it('a hook classification of legacy answers a 2026-only spec method with a plain −32601 (era gate by registry absence)', async () => { - const protocol = new HookedProtocol(); - protocol.verdict = () => ({ era: 'legacy' }); - // Even an installed handler cannot shadow the era gate. - protocol.setRequestHandler('server/discover', { params: z.looseObject({}) }, () => ({}) as Result); - const { peerTx, sent } = await wire(protocol); - - await peerTx.send({ jsonrpc: '2.0', id: 3, method: 'server/discover', params: {} }); - await flush(); - - expect(sent).toHaveLength(1); - const response = sent[0] as JSONRPCErrorResponse; - expect(isJSONRPCErrorResponse(response)).toBe(true); - expect(response.error).toEqual({ code: -32_601, message: 'Method not found' }); - await protocol.close(); - }); -}); - -describe('hook classification on a BOUND instance is validated like an edge classification', () => { - it('a legacy-classified request on a modern-bound instance answers −32004 with the supported list', async () => { - const protocol = new HookedProtocol(); - protocol.verdict = () => ({ era: 'legacy' }); - const { peerTx, sent } = await wire(protocol); - setNegotiatedProtocolVersion(protocol, MODERN); - - await peerTx.send({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} }); + await peerTx.send({ jsonrpc: '2.0', id: 8, method: 'tools/list', params: {} }); await flush(); expect(sent).toHaveLength(1); - const error = (sent[0] as JSONRPCErrorResponse).error as { code: number; data?: { supported?: string[] } }; - expect(error.code).toBe(-32_004); - expect(Array.isArray(error.data?.supported)).toBe(true); - await protocol.close(); - }); - - it('a legacy-classified notification on a modern-bound instance is dropped (no handler invocation, no response)', async () => { - const protocol = new HookedProtocol(); - protocol.verdict = () => ({ era: 'legacy' }); - let invoked = 0; - protocol.fallbackNotificationHandler = async () => { - invoked += 1; - }; - const { peerTx, sent, errors } = await wire(protocol); - setNegotiatedProtocolVersion(protocol, MODERN); - - await peerTx.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); - await flush(); - - expect(invoked).toBe(0); - expect(sent).toHaveLength(0); - expect(errors.length).toBeGreaterThan(0); + expect(isJSONRPCResultResponse(sent[0] as JSONRPCMessage)).toBe(true); + expect((sent[0] as JSONRPCResultResponse).result).toEqual({ tools: [] }); await protocol.close(); }); }); @@ -252,4 +170,19 @@ describe("'drop' verdict", () => { expect(sent).toHaveLength(0); await protocol.close(); }); + + it('responses are never consulted: an inbound response keeps todays correlation path', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => 'drop'; + const { peerTx, sent } = await wire(protocol); + + // An unsolicited response does not reach the hook (it is not a request + // or notification); it surfaces through the response-correlation path. + await peerTx.send({ jsonrpc: '2.0', id: 99, result: {} }); + await flush(); + + expect(protocol.consulted).toHaveLength(0); + expect(sent).toHaveLength(0); + await protocol.close(); + }); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 76244d12b3..408f4be340 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -43,9 +43,9 @@ export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerR export { PerRequestHTTPServerTransport } from './server/perRequestTransport.js'; export type { ServerOptions } from './server/server.js'; export { Server } from './server/server.js'; -// StdioServerTransport is exported from the './stdio' subpath — server stdio has only type-level Node -// imports (erased at compile time), but matching the client's `./stdio` subpath gives consumers a -// consistent shape across packages. +// StdioServerTransport and the serveStdio entry are exported from the './stdio' subpath — server stdio +// has only type-level Node imports (erased at compile time), but matching the client's `./stdio` subpath +// gives consumers a consistent shape across packages. export type { EventId, EventStore, diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 00f35ddeac..3b4f45ab3d 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -59,7 +59,13 @@ import { WebStandardStreamableHTTPServerTransport } from './streamableHttp.js'; * ------------------------------------------------------------------------ */ /** - * Per-request construction context handed to an {@linkcode McpServerFactory}. + * Construction context handed to an {@linkcode McpServerFactory}. + * + * Both serving entries call the factory with this context whenever they need + * a fresh instance: {@linkcode createMcpHandler} once per HTTP request, and + * `serveStdio` (from `@modelcontextprotocol/server/stdio`) once per + * connection — plus once for a `server/discover` probe instance that is + * discarded again if the client falls back to `initialize`. * * Zero-argument factories remain assignable unchanged; the context exists for * factories that vary by principal or era (for example multi-tenant servers @@ -68,22 +74,30 @@ import { WebStandardStreamableHTTPServerTransport } from './streamableHttp.js'; */ export interface McpRequestContext { /** - * The protocol era of the request the constructed instance will serve: - * `modern` for 2026-07-28 (per-request envelope) traffic, `legacy` for - * 2025-era traffic served through the `legacy: 'stateless'` slot. + * The protocol era the constructed instance will serve: `modern` for + * 2026-07-28 (per-request envelope) traffic, `legacy` for 2025-era + * traffic. Under {@linkcode createMcpHandler} a `legacy` instance serves + * one request through the `legacy: 'stateless'` slot; under `serveStdio` + * it serves a connection that opened with the 2025 handshake and stays + * pinned to that era for its lifetime. */ era: 'legacy' | 'modern'; - /** Validated authentication information passed by the caller of the handler face (pass-through). */ + /** + * Validated authentication information passed by the caller of the + * handler face (pass-through; HTTP only — `serveStdio` never sets it). + */ authInfo?: AuthInfo; - /** The original HTTP request being served, when available. */ + /** The original HTTP request being served, when available (HTTP only — `serveStdio` never sets it). */ requestInfo?: Request; } /** * A factory producing a fresh {@linkcode McpServer} (or low-level - * {@linkcode Server}) instance for one request. The same factory backs both - * the modern path and the `legacy: 'stateless'` slot — define your tools, - * resources and prompts once and serve them to both eras. + * {@linkcode Server}) instance for one serving unit: one HTTP request under + * {@linkcode createMcpHandler}, or one connection (or one discarded + * `server/discover` probe) under `serveStdio`. The same factory backs every + * era either entry serves — define your tools, resources and prompts once and + * serve them to both eras. */ export type McpServerFactory = (ctx: McpRequestContext) => McpServer | Server | Promise; diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts new file mode 100644 index 0000000000..e166c4298f --- /dev/null +++ b/packages/server/src/server/serveStdio.ts @@ -0,0 +1,663 @@ +/** + * `serveStdio` — the stdio entry point for serving the 2026-07-28 protocol + * revision on a long-lived connection, with 2025-era serving as the default + * for clients that open with the `initialize` handshake. + * + * The entry owns the stdio transport and the era decision for the connection. + * It classifies the connection's opening exchange exactly once (using the + * same body-primary rules as the HTTP entry), constructs ONE server instance + * from the consumer's factory for the era the client opened with, pins that + * instance for the lifetime of the connection, and passes every later message + * straight through to it. No per-message era classification ever runs after + * the connection is pinned — exactly mirroring how `createMcpHandler` + * classifies an HTTP request before any instance exists. + * + * The opening exchange: + * + * - An `initialize` request (or any claim-less message) opens a 2025-era + * session: the factory builds a legacy instance and the connection is + * pinned to it (`legacy: 'serve'`, the default). With `legacy: 'reject'` + * the opening is answered with the unsupported-protocol-version error + * naming the supported modern revisions instead. + * - A request carrying a valid per-request `_meta` envelope naming a + * supported modern revision pins the connection to a modern instance + * (era-marked and given the modern-only handlers, exactly like the HTTP + * entry's modern path). + * - A `server/discover` probe is answered by an optimistically built modern + * instance but does NOT pin the connection yet: the spec's stdio + * backward-compatibility flow lets a client probe first and then either + * continue with modern requests (which pins the connection modern) or fall + * back to the `initialize` handshake when no mutually supported modern + * revision exists — in which case the probe instance is discarded and a + * fresh legacy instance serves the handshake. + * - Once the modern era is pinned, a later claim-less `initialize` is + * rejected with the unsupported-protocol-version error naming the supported + * revisions (the spec recommends naming them in any error returned to + * `initialize`, and forbids falling back once the modern era is confirmed). + * + * Every instance the factory produces serves exactly one era; the ambiguity + * of the opening exchange lives entirely in this entry. In the probe-fallback + * case the factory is called twice (once for the discarded probe instance, + * once for the legacy instance), so factories should be cheap and + * side-effect-free to construct — the same expectation `createMcpHandler` + * already sets for per-request construction. + * + * Hand-constructed servers connected directly to a `StdioServerTransport` + * are unaffected by this entry: they keep serving the 2025-era protocol they + * were written for. + */ +import type { + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + MessageClassification, + MessageExtraInfo, + RequestId, + Transport, + TransportSendOptions +} from '@modelcontextprotocol/core'; +import { + carriesValidModernEnvelopeClaim, + envelopeClaimVersion, + hasEnvelopeClaim, + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCRequest, + isJSONRPCResultResponse, + modernOnlyStrictRejection, + ProtocolErrorCode, + requestMetaOf, + setNegotiatedProtocolVersion, + SUPPORTED_MODERN_PROTOCOL_VERSIONS, + UnsupportedProtocolVersionError, + validateEnvelopeMeta +} from '@modelcontextprotocol/core'; + +import type { McpServerFactory } from './createMcpHandler.js'; +import { McpServer } from './mcp.js'; +import type { Server } from './server.js'; +import { installModernOnlyHandlers } from './server.js'; +import { StdioServerTransport } from './stdio.js'; + +/** Options for {@linkcode serveStdio}. */ +export interface ServeStdioOptions { + /** + * How a 2025-era opening (an `initialize` request, or any claim-less + * message) is handled: + * + * - `'serve'` (default) — the connection is pinned to a 2025-era instance + * from the same factory and served exactly as a hand-wired stdio server + * serves it today. + * - `'reject'` — the opening request is answered with the + * unsupported-protocol-version error naming the supported modern + * revisions (claim-less notifications are dropped); the connection + * stays open for a modern opening. + */ + legacy?: 'serve' | 'reject'; + /** + * Bring your own transport (for example a `StdioServerTransport` + * constructed over a Unix domain socket or TCP stream, per the stdio + * binding's custom-transport guidance). Defaults to a + * {@linkcode StdioServerTransport} over the current process's stdio. The + * entry owns the transport: it starts it, receives every inbound message, + * and closes it when the connection ends. + */ + transport?: Transport; + /** Callback for out-of-band errors (reporting only; it never alters what is written to the wire). */ + onerror?: (error: Error) => void; +} + +/** The handle returned by {@linkcode serveStdio}. */ +export interface StdioServerHandle { + /** Tears the connection down: closes the pinned instance (if any) and the underlying transport. */ + close(): Promise; +} + +/* ------------------------------------------------------------------------ * + * Per-instance channel + * ------------------------------------------------------------------------ */ + +/** + * The transport a pinned instance is connected to: a thin channel that writes + * through to the entry-owned wire transport and receives the messages the + * entry forwards. The wire transport itself is never handed to an instance — + * that is what lets the entry discard an optimistic probe instance (close the + * channel) without tearing down the connection. + */ +class StdioConnectionChannel implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: T, extra?: MessageExtraInfo) => void; + + private _closed = false; + /** Request ids the entry delivered to the instance that the instance has not yet answered. */ + private readonly _pendingRequests = new Set(); + private _drainWaiters: Array<() => void> = []; + + constructor( + private readonly _wire: Transport, + private readonly _onInstanceClose: () => void + ) {} + + async start(): Promise { + // The entry already started the wire transport; connecting an + // instance to its channel must not start anything again. + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + // The instance answered a delivered request: settle it whether or + // not the wire write below succeeds (write failures surface + // through the wire's own error reporting). + const { id } = message; + if (id !== undefined) { + this._settle(id); + } + } + if (this._closed) { + // A discarded or torn-down instance has nowhere to write; late + // sends are dropped. + return; + } + return this._wire.send(message, options); + } + + setProtocolVersion = (version: string): void => { + this._wire.setProtocolVersion?.(version); + }; + + /** Forwards one inbound message to the connected instance. */ + deliver(message: JSONRPCMessage, extra?: MessageExtraInfo): void { + if (this._closed) { + return; + } + if (isJSONRPCRequest(message)) { + this._pendingRequests.add(message.id); + } + this.onmessage?.(message, extra); + } + + /** + * Resolves once every request delivered to the instance has been answered + * through {@linkcode send} (or the channel has been closed and nothing + * further can be answered). Used by the probe-discard path so a probe + * request the entry accepted is never silently dropped. + */ + async whenRequestsAnswered(): Promise { + if (this._closed || this._pendingRequests.size === 0) { + return; + } + await new Promise(resolve => this._drainWaiters.push(resolve)); + } + + async close(): Promise { + if (this._closed) { + return; + } + this._closed = true; + // Nothing further can be answered through a closed channel; release + // anyone waiting on in-flight answers. + this._pendingRequests.clear(); + this._releaseDrainWaiters(); + try { + this._onInstanceClose(); + } finally { + this.onclose?.(); + } + } + + private _settle(id: RequestId): void { + this._pendingRequests.delete(id); + if (this._pendingRequests.size === 0) { + this._releaseDrainWaiters(); + } + } + + private _releaseDrainWaiters(): void { + const waiters = this._drainWaiters; + this._drainWaiters = []; + for (const waiter of waiters) { + waiter(); + } + } +} + +/* ------------------------------------------------------------------------ * + * Opening-exchange classification + * ------------------------------------------------------------------------ */ + +interface EnvelopeIssue { + key: string; + problem: string; +} + +type OpeningClassification = + /** A 2025-era opening: `initialize`, or any message without an envelope claim. */ + | { kind: 'legacy'; reason: 'initialize' | 'no-claim'; requestedVersion?: string } + /** A valid envelope claim naming a modern revision this entry serves. */ + | { kind: 'modern'; revision: string; classification: MessageClassification } + /** A present envelope claim whose envelope is malformed. */ + | { kind: 'invalid-envelope'; issue: EnvelopeIssue } + /** A valid envelope claim naming a revision this entry does not serve (unknown future or 2025-era). */ + | { kind: 'unsupported-revision'; requested: string }; + +/** + * Classifies one message of the opening exchange with the same body-primary + * rules the HTTP entry applies per request: `initialize` is the legacy + * handshake unless it carries a valid modern envelope claim; a present claim + * is validated (never silently ignored); a claim-less message is 2025-era + * traffic. There is no header layer on stdio, so the body is the only signal. + */ +function classifyOpeningMessage(message: JSONRPCRequest | JSONRPCNotification): OpeningClassification { + const params = message.params; + + if (message.method === 'initialize' && !carriesValidModernEnvelopeClaim(params)) { + const requestedVersion = + params !== null && typeof params === 'object' && typeof (params as { protocolVersion?: unknown }).protocolVersion === 'string' + ? ((params as { protocolVersion: string }).protocolVersion as string) + : undefined; + return { kind: 'legacy', reason: 'initialize', ...(requestedVersion !== undefined && { requestedVersion }) }; + } + + if (!hasEnvelopeClaim(params)) { + return { kind: 'legacy', reason: 'no-claim' }; + } + + // A present claim is validated, never silently ignored — a malformed + // envelope behind the claim is an invalid-params answer, not a fall back + // to legacy serving (mirrors the HTTP entry's envelope rung). + const meta = requestMetaOf(params); + const issues = meta === undefined ? [] : validateEnvelopeMeta(meta); + const firstIssue = issues[0]; + if (firstIssue !== undefined) { + return { kind: 'invalid-envelope', issue: firstIssue }; + } + + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion === undefined || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedVersion)) { + // The claim names a revision this entry does not serve (an unknown + // future revision, or a 2025-era revision delivered via the envelope + // mechanism) — answered like the HTTP entry's modern path. + return { kind: 'unsupported-revision', requested: claimedVersion ?? 'unknown' }; + } + + return { kind: 'modern', revision: claimedVersion, classification: { era: 'modern', revision: claimedVersion } }; +} + +/* ------------------------------------------------------------------------ * + * The entry + * ------------------------------------------------------------------------ */ + +interface ConnectedInstance { + product: McpServer | Server; + channel: StdioConnectionChannel; +} + +type EntryState = + /** Waiting for the connection's opening message. */ + | { phase: 'opening' } + /** A `server/discover` probe was answered; the era is not pinned yet. */ + | { phase: 'probe'; instance: ConnectedInstance } + /** The connection is pinned to one instance serving one era. */ + | { phase: 'pinned'; era: 'legacy' | 'modern'; instance: ConnectedInstance } + | { phase: 'closed' }; + +/** + * Serves MCP over stdio from a server factory, owning the era decision for + * the connection: the opening exchange selects the era, ONE instance from the + * factory is pinned for the connection lifetime, and everything after passes + * straight through to it. See the module documentation for the opening rules. + * + * ```ts + * import { serveStdio } from '@modelcontextprotocol/server/stdio'; + * + * serveStdio(() => { + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + * // register tools/resources/prompts once — the same factory serves both eras + * return server; + * }); + * ``` + */ +export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions = {}): StdioServerHandle { + const legacyMode = options.legacy ?? 'serve'; + const wire = options.transport ?? new StdioServerTransport(); + + let state: EntryState = { phase: 'opening' }; + /** Channel currently being discarded (its close must not tear the connection down). */ + let discarding: StdioConnectionChannel | undefined; + let closing = false; + + /** + * Whether the connection has been torn down (`handle.close()` or the wire + * closing). The opening arms re-check this after every await: a close can + * race factory construction, and the continuation must neither resurrect + * the connection state nor keep a late-resolved instance around. + */ + const isTornDown = (): boolean => closing || state.phase === 'closed'; + + const reportError = (error: Error) => { + try { + options.onerror?.(error); + } catch { + // Reporting must never affect the wire. + } + }; + + const writeErrorResponse = (id: RequestId, code: number, message: string, data?: unknown): Promise => + wire + .send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined && { data }) } }) + .catch(error => reportError(toError(error))); + + /** Answers a 2025-era request the entry will not serve (the modern-only rejection cells). */ + const answerLegacyRejection = ( + request: JSONRPCRequest, + reason: 'initialize' | 'no-claim', + requestedVersion?: string + ): Promise => { + const rejection = modernOnlyStrictRejection( + { kind: 'legacy', reason, ...(requestedVersion !== undefined && { requestedVersion }) }, + SUPPORTED_MODERN_PROTOCOL_VERSIONS + ); + if (rejection === undefined) { + return Promise.resolve(); + } + reportError(new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection.cell}): ${rejection.message}`)); + return writeErrorResponse(request.id, rejection.code, rejection.message, rejection.data); + }; + + const onInstanceClosed = (channel: StdioConnectionChannel) => { + if (closing || channel === discarding) { + return; + } + // The pinned (or probe) instance was closed from the instance side: + // the connection is over. + void closeAll(); + }; + + const connectInstance = async (era: 'legacy' | 'modern', revision?: string): Promise => { + const product = await factory({ era }); + const server = product instanceof McpServer ? product.server : product; + if (era === 'modern') { + // Era-write at instance binding, then modern-only handler + // installation — the same helpers the HTTP entry's modern path + // uses, before the instance is connected. + setNegotiatedProtocolVersion(server, revision); + installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS); + } + const channel: StdioConnectionChannel = new StdioConnectionChannel(wire, () => onInstanceClosed(channel)); + await product.connect(channel); + return { product, channel }; + }; + + /** Closes an instance whose factory resolved only after the connection was torn down. */ + const disposeLateInstance = (instance: ConnectedInstance): Promise => + instance.product.close().catch(error => reportError(toError(error))); + + const discardProbeInstance = async (instance: ConnectedInstance): Promise => { + // The probe instance served only the discover exchange; closing its + // channel must not tear down the connection the fallback is about to + // continue on. + discarding = instance.channel; + try { + // A probe request the entry accepted must never go silently + // unanswered: a client may pipeline its fallback `initialize` + // straight behind `server/discover` without waiting, and closing + // the instance aborts whatever it still has in flight. Let the + // in-flight DiscoverResult reach the wire before the instance is + // closed; the probe instance only ever receives `server/discover`, + // whose entry-installed handler always answers promptly. + await instance.channel.whenRequestsAnswered(); + await instance.product.close(); + } catch (error) { + reportError(toError(error)); + } finally { + discarding = undefined; + } + }; + + const processMessage = async (message: JSONRPCMessage): Promise => { + if (state.phase === 'closed') { + return; + } + + if (state.phase === 'pinned') { + if ( + state.era === 'modern' && + isJSONRPCRequest(message) && + message.method === 'initialize' && + !carriesValidModernEnvelopeClaim(message.params) + ) { + // The modern era is confirmed for this connection; a late + // legacy handshake is answered with the version error naming + // the supported revisions (the specification recommends + // naming them in any error returned to `initialize`, and + // rules out falling back once the modern era is confirmed). + const requestedVersion = + message.params !== null && + typeof message.params === 'object' && + typeof (message.params as { protocolVersion?: unknown }).protocolVersion === 'string' + ? ((message.params as { protocolVersion: string }).protocolVersion as string) + : undefined; + await answerLegacyRejection(message, 'initialize', requestedVersion); + return; + } + state.instance.channel.deliver(message); + return; + } + + // Negotiation window ('opening' | 'probe'). + if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) { + // A JSON-RPC response before any era is pinned: nothing has been + // asked of the client yet, so there is nothing it can answer. + reportError(new Error('Discarded a JSON-RPC response received before the connection negotiated an era')); + return; + } + + const opening = classifyOpeningMessage(message); + switch (opening.kind) { + case 'invalid-envelope': { + const detail = `Invalid _meta envelope for protocol revision 2026-07-28: ${opening.issue.key}: ${opening.issue.problem}`; + if (isJSONRPCRequest(message)) { + await writeErrorResponse(message.id, ProtocolErrorCode.InvalidParams, detail, { envelope: opening.issue }); + } else { + reportError(new Error(`Discarded a notification with a malformed envelope: ${detail}`)); + } + return; + } + case 'unsupported-revision': { + if (isJSONRPCRequest(message)) { + const error = new UnsupportedProtocolVersionError({ + supported: [...SUPPORTED_MODERN_PROTOCOL_VERSIONS], + requested: opening.requested + }); + reportError(error); + await writeErrorResponse(message.id, error.code, error.message, error.data); + } else { + reportError(new Error(`Discarded a notification claiming unsupported protocol revision ${opening.requested}`)); + } + return; + } + case 'modern': { + if (isJSONRPCRequest(message) && message.method === 'server/discover') { + if (state.phase === 'probe') { + // A repeated probe is answered by the same optimistic + // instance and the negotiation window stays open: only + // a non-discover enveloped request commits the + // connection to the modern era, so a later fallback + // `initialize` is still served by a fresh legacy + // instance. + state.instance.channel.deliver(message, { classification: opening.classification }); + return; + } + // Probe: answer from an optimistically built modern + // instance so the advertisement reflects the real server + // definition, but do not pin the connection yet — the + // client may still fall back to `initialize` when it + // shares no modern revision with the advertisement. + const instance = await connectInstance('modern', opening.revision); + if (isTornDown()) { + // The connection was torn down while the factory was + // building the probe instance: dispose of it and stay + // closed instead of resurrecting the negotiation + // window; nothing is delivered or answered. + await disposeLateInstance(instance); + return; + } + state = { phase: 'probe', instance }; + instance.channel.deliver(message, { classification: opening.classification }); + return; + } + if (state.phase === 'probe') { + if (isJSONRPCNotification(message)) { + // An enveloped notification during the negotiation + // window (for example a notifications/cancelled for + // the probe itself) is delivered to the probe instance + // without committing the era: only a non-discover + // enveloped request pins the connection, so a later + // fallback `initialize` is still served by a fresh + // legacy instance. + state.instance.channel.deliver(message, { classification: opening.classification }); + return; + } + // The probe was followed by a modern request: the client + // committed to the modern era — pin the probe instance. + state = { phase: 'pinned', era: 'modern', instance: state.instance }; + } else { + const instance = await connectInstance('modern', opening.revision); + if (isTornDown()) { + // Closed while the factory was building the modern + // instance: dispose of it and stay closed. + await disposeLateInstance(instance); + return; + } + state = { phase: 'pinned', era: 'modern', instance }; + } + state.instance.channel.deliver(message, { classification: opening.classification }); + return; + } + case 'legacy': { + if (legacyMode === 'reject') { + if (isJSONRPCRequest(message)) { + await answerLegacyRejection(message, opening.reason, opening.requestedVersion); + } + // Claim-less notifications are accepted and dropped (the + // stdio analog of the HTTP entry's 202-and-drop); the + // connection stays open for a modern opening. + return; + } + if (state.phase === 'probe') { + // Probe-then-fallback: the client probed, found no + // mutually supported modern revision, and fell back to + // the 2025 handshake on the same connection. The probe + // instance is discarded; a fresh legacy instance serves + // the handshake. + await discardProbeInstance(state.instance); + if (isTornDown()) { + // Closed while the probe was being discarded: stay closed. + return; + } + state = { phase: 'opening' }; + } + const instance = await connectInstance('legacy'); + if (isTornDown()) { + // Closed while the factory was building the legacy + // instance: dispose of it and stay closed. + await disposeLateInstance(instance); + return; + } + state = { phase: 'pinned', era: 'legacy', instance }; + state.instance.channel.deliver(message); + return; + } + } + }; + + // Inbound messages are processed strictly in arrival order: the queue + // absorbs anything that arrives while the opening exchange is still being + // decided (factory construction and instance connection are async). + const queue: JSONRPCMessage[] = []; + let pumping = false; + const pump = async (): Promise => { + if (pumping) { + return; + } + pumping = true; + try { + while (queue.length > 0) { + const message = queue.shift()!; + try { + await processMessage(message); + } catch (error) { + // Every arm of processMessage that answers a request does + // so through writeErrorResponse (which never throws — wire + // failures are routed to onerror) and returns right after, + // so an error escaping to here means the request was never + // answered. Answer it now: a throwing factory or a failed + // connect during the opening exchange must not leave the + // client's request hanging (the stdio analog of the HTTP + // entry's internal-server-error response). Notifications + // carry no id to answer and are only reported. + if (isJSONRPCRequest(message)) { + await writeErrorResponse(message.id, ProtocolErrorCode.InternalError, 'Internal server error'); + } + reportError(toError(error)); + } + } + } finally { + pumping = false; + } + }; + + const closeAll = async (): Promise => { + if (closing || state.phase === 'closed') { + return; + } + closing = true; + const current = state; + state = { phase: 'closed' }; + if (current.phase === 'probe' || current.phase === 'pinned') { + await current.instance.product.close().catch(error => reportError(toError(error))); + } + await wire.close().catch(error => reportError(toError(error))); + }; + + wire.onmessage = (message: JSONRPCMessage) => { + queue.push(message); + void pump(); + }; + wire.onerror = error => { + reportError(error); + if (state.phase === 'probe' || state.phase === 'pinned') { + state.instance.channel.onerror?.(error); + } + }; + wire.onclose = () => { + if (closing || state.phase === 'closed') { + return; + } + closing = true; + const current = state; + state = { phase: 'closed' }; + if (current.phase === 'probe' || current.phase === 'pinned') { + void current.instance.product.close().catch(error => reportError(toError(error))); + } + }; + + const started = wire.start().catch(error => { + reportError(toError(error)); + throw error; + }); + // Surface a failed start through onerror (above); close() still resolves. + started.catch(() => {}); + + return { + close: async () => { + await started.catch(() => {}); + await closeAll(); + } + }; +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 20e2995923..a0cd296ddb 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -16,7 +16,6 @@ import type { Implementation, InitializeRequest, InitializeResult, - JSONRPCNotification, JSONRPCRequest, JsonSchemaType, jsonSchemaValidator, @@ -24,7 +23,6 @@ import type { ListRootsResult, LoggingLevel, LoggingMessageNotification, - MessageClassification, MessageExtraInfo, NotificationMethod, NotificationOptions, @@ -41,14 +39,9 @@ import type { import { assertValidCacheHint, attachCacheHintFallback, - classifyInboundMessage, codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - envelopeClaimVersion, - FIRST_MODERN_PROTOCOL_VERSION, - hasEnvelopeClaim, - isModernProtocolVersion, LATEST_PROTOCOL_VERSION, legacyProtocolVersions, LoggingLevelSchema, @@ -58,14 +51,10 @@ import { Protocol, ProtocolError, ProtocolErrorCode, - requestMetaOf, SdkError, - SdkErrorCode, - SUPPORTED_MODERN_PROTOCOL_VERSIONS, - validateEnvelopeMeta + SdkErrorCode } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; -import * as z from 'zod/v4'; export type ServerOptions = ProtocolOptions & { /** @@ -95,52 +84,6 @@ export type ServerOptions = ProtocolOptions & { */ jsonSchemaValidator?: jsonSchemaValidator; - /** - * Which protocol eras this server serves on its long-lived connection - * (e.g. stdio): the 2025-era `initialize` family, the 2026-07-28 - * per-request-envelope revision, or both. - * - * - `'legacy'` (the default) preserves exactly what existing code was - * written for: the server speaks the 2025-era protocol negotiated via - * `initialize`, never registers or advertises `server/discover`, and - * upgrading the SDK changes nothing about what the instance puts on the - * wire. - * - `'dual-era'` serves BOTH eras on the same connection, selecting the - * era per message: `initialize`-negotiated 2025 traffic is served as - * before, while messages carrying the 2026-07-28 per-request `_meta` - * envelope (including `server/discover`) are served on the modern era. - * Declaring dual-era support is an explicit act — the consumer asserts - * that the server is ready to serve modern-era requests. - * - `'modern'` is strict 2026-07-28-only: requests without the - * per-request envelope (including `initialize`) are answered with the - * unsupported-protocol-version error naming the supported revisions. - * - * Declaring `'dual-era'` or `'modern'` automatically adds the SDK's - * supported modern revisions to - * {@linkcode ProtocolOptions.supportedProtocolVersions}, and `'modern'` - * serves only those: a strict instance's supported-versions list (what - * `server/discover` advertises and version-mismatch errors name) is its - * modern subset. - * - * Opting in is one option away and the transport stays unchanged: - * - * ```ts - * const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); - * await server.connect(new StdioServerTransport()); - * ``` - * - * A 2026-era revision in {@linkcode ProtocolOptions.supportedProtocolVersions} - * requires `'dual-era'` or `'modern'`; passing one on a (default) - * `'legacy'` instance throws a `TypeError` at construction. - * - * Per-request HTTP serving via `createMcpHandler` does not use this - * option: the entry classifies each request and binds the per-request - * instance itself. - * - * @default 'legacy' - */ - eraSupport?: 'legacy' | 'dual-era' | 'modern'; - /** * Cache hints for the cacheable results of the 2026-07-28 protocol * revision (`ttlMs` / `cacheScope`), keyed by operation. The cacheable @@ -162,46 +105,14 @@ export type ServerOptions = ProtocolOptions & { cacheHints?: Partial>; }; -/** - * Permissive params schema for the `server/discover` registration on servers - * that declared modern-era support. The discover request carries only the - * per-request `_meta` envelope, which the protocol layer lifts and validates - * before dispatch — and a long-lived dual-era instance is never bound to a - * single era, so the spec-method registration form (which resolves its - * dispatch schema from the instance era) cannot be used here. - */ -const DISCOVER_PARAMS_SCHEMA = z.looseObject({}); - -/** - * Whether a message's params carry a per-request envelope claim that is both - * well-formed and names a modern protocol revision. - * - * The per-message form of the inbound classifier's `initialize` precedence - * rule: only such a claim overrides the `initialize` ⇒ legacy-handshake - * classification — a message carrying a valid modern envelope is a modern - * request regardless of its method name, and the modern era then answers - * `initialize` exactly like any other method it does not define - * (method-not-found). A malformed claim, or one naming a pre-2026 revision, - * keeps the legacy-handshake routing unchanged. - */ -function carriesValidModernEnvelopeClaim(params: unknown): boolean { - if (!hasEnvelopeClaim(params)) { - return false; - } - const claimedVersion = envelopeClaimVersion(params); - if (claimedVersion === undefined || !isModernProtocolVersion(claimedVersion)) { - return false; - } - const meta = requestMetaOf(params); - return meta !== undefined && validateEnvelopeMeta(meta).length === 0; -} - /* - * Package-internal hooks for the per-request (2026-07-28) HTTP serving entry. + * Package-internal hooks for the 2026-07-28 serving entries (the per-request + * HTTP entry `createMcpHandler` and the connection-pinned stdio entry + * `serveStdio`). * * The connection-scoped client-identity fields and the modern-only handler set are - * private to `Server`; the per-request entry in this package needs to write/install - * them on the fresh instance it gets from a consumer factory. The static initializer + * private to `Server`; the serving entries in this package need to write/install + * them on the fresh instance they get from a consumer factory. The static initializer * below hands these module-scoped closures privileged access; the exported wrappers * are imported by sibling modules in this package only and are deliberately NOT * re-exported from the package index (they are not public API). @@ -274,14 +185,6 @@ export class Server extends Protocol { private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; - private _eraSupport: 'legacy' | 'dual-era' | 'modern'; - /** - * The protocol version a legacy `initialize` handshake negotiated on a - * dual-era instance. A dual-era instance is never bound to a single era - * (the era is selected per message), so the handshake result is recorded - * here only for the initialize-scoped accessor. - */ - private _dualEraInitializeVersion?: string; private _cacheHints?: ServerOptions['cacheHints']; /** @@ -300,7 +203,6 @@ export class Server extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); - this._eraSupport = options?.eraSupport ?? 'legacy'; // Configured cache hints fail loudly at construction time (before any // handler registration consults them). @@ -316,45 +218,13 @@ export class Server extends Protocol { this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); - if (this._eraSupport === 'legacy') { - // The default preserves exactly what the code was written for: - // 2025-era serving only, nothing 2026-era registered or - // advertised. Serving a 2026-era revision is a declared act — a - // modern revision in the supported list without that declaration - // is a configuration error, never a silent behavior change. - const modernVersions = modernProtocolVersions(this._supportedProtocolVersions); - if (modernVersions.length > 0) { - throw new TypeError( - `supportedProtocolVersions contains the protocol revision ${modernVersions[0]}, which this server does not serve ` + - `with the default eraSupport of 'legacy'. Declare { eraSupport: 'dual-era' } (serve both eras) or ` + - `{ eraSupport: 'modern' } (2026-era only) to serve it.` - ); - } - } else { - // server/discover is registered (and modern revisions advertised) - // only on servers that declared modern-era support; the served - // modern revisions are added to the supported list so the - // advertisement and version-mismatch errors name them (a new - // array — the shared default constant is never mutated). - const missing = SUPPORTED_MODERN_PROTOCOL_VERSIONS.filter(version => !this._supportedProtocolVersions.includes(version)); - if (missing.length > 0) { - this._supportedProtocolVersions = [...this._supportedProtocolVersions, ...missing]; - } - this.setRequestHandler('server/discover', { params: DISCOVER_PARAMS_SCHEMA }, () => this._ondiscover()); - if (this._eraSupport === 'modern') { - // A strict modern-only server serves only modern revisions, so - // the supported list is reduced to its modern subset — keeping - // the legacy entries would advertise revisions the instance - // never serves in the unsupported-protocol-version error's - // supported list, and `initialize` (the only other consumer of - // the legacy entries) is unreachable on a strict instance. - this._supportedProtocolVersions = modernProtocolVersions(this._supportedProtocolVersions); - // A strict modern-only server is bound to the modern era from - // construction: requests classified into the 2025 era are - // answered with the typed unsupported-protocol-version error - // naming the supported revisions, never served. - this._negotiatedProtocolVersion = this._supportedProtocolVersions[0]; - } + // server/discover is installed only when the supported-versions list + // carries a modern revision: a legacy-only server keeps answering -32601. + // A hand-constructed instance is never era-bound, so the handler stays + // unreachable behind the era gate until a serving entry (createMcpHandler, + // serveStdio) marks the instance as serving the 2026-07-28 era. + if (modernProtocolVersions(this._supportedProtocolVersions).length > 0) { + this.setRequestHandler('server/discover', () => this._ondiscover()); } if (this._capabilities.logging) { @@ -362,35 +232,6 @@ export class Server extends Protocol { } } - /** - * Per-message era classification for long-lived dual-era channels (e.g. a - * stdio server that declared modern-era support). Active only when the - * consumer opted in: default (`'legacy'`) instances return `undefined`, - * which keeps their dispatch byte-identical to today's. Transport-edge - * classification (the per-request HTTP entry) always wins and never - * reaches this hook. - */ - protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { - if (this._eraSupport === 'legacy') { - return undefined; - } - // `initialize` is the legacy handshake by definition — unless the - // message carries a valid envelope claim naming a modern revision, in - // which case the claim wins: the message is classified like any other - // enveloped message and served on the modern era, where the era - // registry answers `initialize` with the same plain method-not-found - // it answers every other method that era does not define. A malformed - // or absent claim, or a claim naming a pre-2026 revision, keeps the - // legacy-handshake classification from the per-message predicate. - if (message.method === 'initialize' && carriesValidModernEnvelopeClaim(message.params)) { - const claimedVersion = envelopeClaimVersion(message.params); - if (claimedVersion !== undefined) { - return { era: 'modern', revision: claimedVersion }; - } - } - return classifyInboundMessage(message); - } - /** * Registers the built-in `logging/setLevel` request handler. * @@ -411,76 +252,19 @@ export class Server extends Protocol { }); } - /** - * Era gate for context-related server→client requests, keyed off the era - * of the request currently being served (its classification). - * - * A long-lived dual-era instance is never bound to a single era, so the - * instance-level outbound era gate alone would let a handler that is - * serving a 2026-era request push a server→client wire request - * (sampling, elicitation, roots) onto the connection. The 2026-07-28 - * revision has no server→client JSON-RPC request channel, so the client - * drops the request and the call hangs until timeout. The request - * context therefore applies the same typed local error a strict - * `'modern'` instance raises, per request: spec methods absent from the - * served era's registry fail fast before anything reaches the transport. - * - * Scope: the context request path only (`ctx.mcpReq.send`, - * `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`). Related - * notifications, requests served on the legacy era, and instance-level - * senders used outside a request context are unaffected. - */ - private _assertContextRequestInServedEra(classification: MessageClassification | undefined, method: string): void { - if (classification === undefined) { - return; - } - const servedCodec = codecForVersion( - classification.revision ?? (classification.era === 'modern' ? FIRST_MODERN_PROTOCOL_VERSION : undefined) - ); - // Mirrors the outbound era gate: only spec methods missing from the - // served era are gated; methods the served era defines (and - // consumer-owned extension methods) resolve exactly as before. - if (servedCodec.hasRequestMethod(method) || !codecForVersion(undefined).hasRequestMethod(method)) { - return; - } - throw new SdkError( - SdkErrorCode.MethodNotSupportedByProtocolVersion, - `Server-to-client requests are not available on protocol revision ${servedCodec.era}: ` + - `'${method}' cannot be sent while serving a request on that revision. ` + - `Servers obtain client input through request results once multi-round-trip support is available.`, - { method, era: servedCodec.era } - ); - } - protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext { // Only create http when there's actual HTTP transport info or auth info const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; - const classification = transportInfo?.classification; - // Context-related server→client requests are gated by the era of the - // request being served (see _assertContextRequestInServedEra); - // related notifications (`notify`, `log`) are unaffected. - const baseSend = ctx.mcpReq.send as (request: { method: string }, ...rest: unknown[]) => Promise; - const send = ((request: { method: string }, ...rest: unknown[]) => { - this._assertContextRequestInServedEra(classification, request.method); - return baseSend(request, ...rest); - }) as BaseContext['mcpReq']['send']; return { ...ctx, mcpReq: { ...ctx.mcpReq, - send, // Deprecated as of protocol version 2026-07-28 (SEP-2577): `log` and // `requestSampling` remain functional during the deprecation window // (at least twelve months). See ServerContext for migration guidance. log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), - elicitInput: async (params, options) => { - this._assertContextRequestInServedEra(classification, 'elicitation/create'); - return this.elicitInput(params, options); - }, - requestSampling: async (params, options) => { - this._assertContextRequestInServedEra(classification, 'sampling/createMessage'); - return this.createMessage(params, options); - } + elicitInput: (params, options) => this.elicitInput(params, options), + requestSampling: (params, options) => this.createMessage(params, options) }, http: hasHttpInfo ? { @@ -733,15 +517,7 @@ export class Server extends Protocol { // The negotiated version is the instance's connection state — it IS // the wire-era selection for everything this instance sends and // receives from here on (legacy handshake ⇒ a legacy-era version). - // The one exception is a dual-era instance: it serves both eras on - // the same long-lived connection, selecting the era per message, so - // the handshake never binds the instance — the result is recorded - // only for the initialize-scoped accessor. - if (this._eraSupport === 'dual-era') { - this._dualEraInitializeVersion = protocolVersion; - } else { - this._negotiatedProtocolVersion = protocolVersion; - } + this._negotiatedProtocolVersion = protocolVersion; this.transport?.setProtocolVersion?.(protocolVersion); return { @@ -802,13 +578,10 @@ export class Server extends Protocol { * 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` names the revision the * request was sent for, while on 2025-era connections this accessor keeps returning the * `initialize`-negotiated version. The accessor remains functional — instances serving the - * 2026-07-28 era report that revision. On a long-lived dual-era instance (`eraSupport: - * 'dual-era'`), where the era is selected per message, the accessor keeps its - * initialize-scoped semantics and reports what a legacy `initialize` handshake negotiated - * (or `undefined` when none ran). + * 2026-07-28 era report that revision. */ getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion ?? this._dualEraInitializeVersion; + return this._negotiatedProtocolVersion; } /** diff --git a/packages/server/src/stdio.ts b/packages/server/src/stdio.ts index 7865c9cedc..deaa8468db 100644 --- a/packages/server/src/stdio.ts +++ b/packages/server/src/stdio.ts @@ -1,8 +1,11 @@ -// Subpath entry for the stdio server transport. +// Subpath entry for stdio serving. // -// Exported separately from the root entry to keep `StdioServerTransport` out of the default bundle +// Exported separately from the root entry to keep the process-stdio surface (`StdioServerTransport` +// and the `serveStdio` entry point, which constructs one by default) out of the default bundle // surface — server stdio has only type-level Node imports, but matching the client's `./stdio` // subpath gives consumers a consistent shape across packages. Import from // `@modelcontextprotocol/server/stdio` only in process-stdio runtimes (Node.js, Bun, Deno). +export type { ServeStdioOptions, StdioServerHandle } from './server/serveStdio.js'; +export { serveStdio } from './server/serveStdio.js'; export { StdioServerTransport } from './server/stdio.js'; diff --git a/packages/server/test/server/discover.test.ts b/packages/server/test/server/discover.test.ts index d9806bd1ef..c2b96da595 100644 --- a/packages/server/test/server/discover.test.ts +++ b/packages/server/test/server/discover.test.ts @@ -1,11 +1,9 @@ /** * `server/discover` machinery + era-aware supported-version list semantics: * - * - the handler is installed ONLY on servers that declare modern-era support - * (`eraSupport: 'dual-era' | 'modern'`); default servers keep answering - * -32601 byte-identically to the deployed fleet, and a modern (2026-07-28+) - * revision in the supported-versions list without that declaration is a - * construction-time TypeError + * - the handler is installed ONLY when the server's supported-versions list + * carries a modern (2026-07-28+) revision; default servers keep answering + * -32601 byte-identically to the deployed fleet * - the advertisement is modern-only (DV-30) and excludes the * listChanged/subscribe-class capabilities (A11 rider — until the * subscriptions/listen milestone lands) @@ -14,10 +12,12 @@ * site, even when the supported list carries one — the guard that must hold * BEFORE any LATEST/SUPPORTED constant bump. * - * The HTTP per-request entry still binds its instances to the modern era - * through the package-internal hook; the `markModern` arm of the harness - * stands in for that path, and the modern-era request shape carries the - * required per-request `_meta` envelope. + * Era is instance state: an inbound `server/discover` is served only by a + * modern-era instance (the method is physically absent from the legacy + * registry). Production marking of modern instances is owned by the + * server-entry milestone; these tests mark instances through the + * package-internal hook the entry will use, and the modern-era request shape + * carries the required per-request `_meta` envelope. */ import type { DiscoverResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; import { @@ -92,7 +92,7 @@ describe('server/discover handler gating', () => { it('a server with a modern revision in its supported list serves discover on a modern-era instance', async () => { const server = new Server( { name: 'modern-server', version: '2.0.0' }, - { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era', instructions: 'hello' } + { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, instructions: 'hello' } ); const response = await sendRaw(server, discoverRequest, { markModern: true }); expect(isJSONRPCResultResponse(response)).toBe(true); @@ -118,10 +118,7 @@ describe('server/discover handler gating', () => { describe('discover advertisement constraints', () => { it('advertises modern-only versions (DV-30): no 2025-era string ever appears in supportedVersions', async () => { - const server = new Server( - { name: 'test', version: '1.0.0' }, - { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } - ); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); const response = await sendRaw(server, discoverRequest, { markModern: true }); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = DiscoverResultSchema.parse(response.result); @@ -143,8 +140,7 @@ describe('discover advertisement constraints', () => { logging: {}, completions: {} }, - supportedProtocolVersions: DUAL_ERA_VERSIONS, - eraSupport: 'dual-era' + supportedProtocolVersions: DUAL_ERA_VERSIONS } ); const response = await sendRaw(server, discoverRequest, { markModern: true }); @@ -170,10 +166,7 @@ describe('discover advertisement constraints', () => { expect(capabilities).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }); // The legacy initialize advertisement still carries the full capability set. - const server = new Server( - { name: 'test', version: '1.0.0' }, - { capabilities, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } - ); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities, supportedProtocolVersions: DUAL_ERA_VERSIONS }); const response = await sendRaw(server, initializeRequest(LATEST_PROTOCOL_VERSION)); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = InitializeResultSchema.parse(response.result); @@ -185,10 +178,7 @@ describe('discover advertisement constraints', () => { describe('era-aware counter-offer ordering (the guard that precedes any constant bump)', () => { it('an unknown requested version is countered with the latest LEGACY version even when the list carries a modern one', async () => { - const server = new Server( - { name: 'test', version: '1.0.0' }, - { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } - ); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); const response = await sendRaw(server, initializeRequest('1999-01-01')); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = InitializeResultSchema.parse(response.result); @@ -201,10 +191,7 @@ describe('era-aware counter-offer ordering (the guard that precedes any constant }); it('an initialize REQUESTING the modern revision is also answered with the latest legacy version (initialize never negotiates a modern era)', async () => { - const server = new Server( - { name: 'test', version: '1.0.0' }, - { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } - ); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS }); const response = await sendRaw(server, initializeRequest(MODERN)); if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = InitializeResultSchema.parse(response.result); diff --git a/packages/server/test/server/dualEraServing.test.ts b/packages/server/test/server/dualEraServing.test.ts deleted file mode 100644 index 32d9b6dd53..0000000000 --- a/packages/server/test/server/dualEraServing.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Long-lived dual-era serving (`eraSupport: 'dual-era'`) on one connection: - * - * - the legacy vertical (initialize → tools/list → tools/call) is served - * exactly as a 2025 server serves it (no 2026 wire fields anywhere); - * - the modern vertical (server/discover → tools/list → tools/call, every - * request carrying the per-request `_meta` envelope) is served on the - * 2026 era on the SAME connection; - * - the long-lived era gate: a message classified into the legacy era asking - * for `server/discover`, `subscriptions/listen`, or any 2026-only method is - * answered with a plain −32601 carrying ZERO 2026 vocabulary in message or - * data (the dedicated leak test — the gate is not structural on a long-lived - * instance, which hosts both registries); the modern-direction denial of - * legacy-only methods mirrors it. - * - Q10-L2: a hand-constructed server with the default `eraSupport` serves a - * scripted 2025 session with today's exact result shapes and zero 2026 - * vocabulary on the wire. - */ -import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - InMemoryTransport, - isJSONRPCErrorResponse, - isJSONRPCResultResponse, - LATEST_PROTOCOL_VERSION, - PROTOCOL_VERSION_META_KEY -} from '@modelcontextprotocol/core'; -import { describe, expect, it } from 'vitest'; -import * as z from 'zod/v4'; - -import { McpServer } from '../../src/server/mcp.js'; - -const MODERN = '2026-07-28'; - -/** - * 2026-era vocabulary that must never leak into a legacy-direction response. - * The gate answers with the same plain `-32601` a 2025 server answers for an - * unknown method — nothing in message or data may reveal that the instance - * also hosts the modern era. - */ -const FORBIDDEN_2026_VOCABULARY = [ - '2026', - 'discover', - 'envelope', - 'modern', - 'dual', - 'era', - '_meta', - 'io.modelcontextprotocol', - 'resultType', - 'protocolVersion', - 'protocol version', - 'subscription' -]; - -/** The 2026-only request methods the era gate must hide from legacy-era traffic. */ -const MODERN_ONLY_METHODS = ['server/discover', 'subscriptions/listen']; - -/** - * Legacy-only methods whose modern-direction denial mirrors the gate. - * (`initialize` is not in this list only because it has its own dedicated - * coverage below: an `initialize` carrying a valid modern envelope claim is - * classified by the claim — the claim wins over the legacy-handshake rule — - * and is denied with the same plain −32601.) - */ -const LEGACY_ONLY_METHODS = ['ping', 'logging/setLevel', 'resources/subscribe']; - -const envelope = (overrides?: Record) => ({ - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {}, - ...overrides -}); - -function buildServer(options?: { eraSupport?: 'legacy' | 'dual-era' | 'modern' }) { - const server = new McpServer( - { name: 'dual-era-test-server', version: '1.0.0' }, - { - capabilities: { tools: {} }, - instructions: 'test instructions', - ...(options?.eraSupport ? { eraSupport: options.eraSupport } : {}) - } - ); - server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ - content: [{ type: 'text', text }] - })); - return server; -} - -async function wire(server: McpServer) { - const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); - const inbound: JSONRPCMessage[] = []; - const waiters = new Map void>(); - peerTx.onmessage = message => { - inbound.push(message); - const id = (message as { id?: string | number }).id; - const waiter = id === undefined ? undefined : waiters.get(id); - if (id !== undefined && waiter) { - waiters.delete(id); - waiter(message); - } - }; - await server.connect(serverTx); - await peerTx.start(); - - const request = (message: JSONRPCRequest): Promise => - new Promise(resolve => { - waiters.set(message.id, resolve); - void peerTx.send(message); - }); - const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); - return { request, notify, inbound, close: () => server.close() }; -} - -const initializeRequest = (id: number): JSONRPCRequest => ({ - jsonrpc: '2.0', - id, - method: 'initialize', - params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } -}); - -describe('dual-era serving on one long-lived connection', () => { - it('serves the legacy vertical and the modern vertical on the same connection, each on its own era', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, notify, close } = await wire(server); - - // --- Legacy vertical: initialize → initialized → tools/list → tools/call. - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - if (isJSONRPCResultResponse(init)) { - expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - expect(JSON.stringify(init)).not.toContain('resultType'); - expect(JSON.stringify(init)).not.toContain('2026'); - } - await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); - - const legacyList = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); - expect(isJSONRPCResultResponse(legacyList)).toBe(true); - if (isJSONRPCResultResponse(legacyList)) { - expect((legacyList.result as { tools: Array<{ name: string }> }).tools.map(tool => tool.name)).toEqual(['echo']); - expect(JSON.stringify(legacyList)).not.toContain('resultType'); - } - - const legacyCall = await request({ - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'echo', arguments: { text: 'legacy leg' } } - }); - expect(isJSONRPCResultResponse(legacyCall)).toBe(true); - if (isJSONRPCResultResponse(legacyCall)) { - expect((legacyCall.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'legacy leg' }]); - expect(JSON.stringify(legacyCall)).not.toContain('resultType'); - } - - // --- Modern vertical on the SAME connection: discover → list → call, - // every request carrying the per-request envelope. - const discover = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(discover)).toBe(true); - if (isJSONRPCResultResponse(discover)) { - const result = discover.result as { supportedVersions?: string[]; resultType?: string }; - expect(result.supportedVersions).toEqual([MODERN]); - expect(result.resultType).toBe('complete'); - } - - const modernList = await request({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(modernList)).toBe(true); - if (isJSONRPCResultResponse(modernList)) { - const result = modernList.result as { tools: Array<{ name: string }>; resultType?: string }; - expect(result.tools.map(tool => tool.name)).toEqual(['echo']); - expect(result.resultType).toBe('complete'); - } - - const modernCall = await request({ - jsonrpc: '2.0', - id: 6, - method: 'tools/call', - params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope() } - }); - expect(isJSONRPCResultResponse(modernCall)).toBe(true); - if (isJSONRPCResultResponse(modernCall)) { - const result = modernCall.result as { content: unknown[]; resultType?: string }; - expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); - expect(result.resultType).toBe('complete'); - } - - // The legacy leg is unaffected by the modern exchanges that ran in between. - const legacyAgain = await request({ jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }); - expect(isJSONRPCResultResponse(legacyAgain)).toBe(true); - expect(JSON.stringify(legacyAgain)).not.toContain('resultType'); - - await close(); - }); - - it('the modern era is reachable without any prior legacy handshake (envelope-first connection)', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - const discover = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(discover)).toBe(true); - await close(); - }); -}); - -describe('long-lived era gate + zero-2026-vocabulary leak test', () => { - it('a legacy-classified request for any 2026-only method answers a plain −32601 with zero 2026 vocabulary in message or data', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - // Establish the legacy leg first — the gate must hold on a connection - // that is actively serving 2025 traffic. - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - - let id = 10; - for (const method of MODERN_ONLY_METHODS) { - // No envelope claim ⇒ classified legacy ⇒ the modern registry must be invisible. - const response = await request({ jsonrpc: '2.0', id: (id += 1), method, params: {} }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - const error = (response as JSONRPCErrorResponse).error; - expect(error.code).toBe(-32_601); - expect(error.message).toBe('Method not found'); - expect(error.data).toBeUndefined(); - - const serialized = JSON.stringify({ error, id: null }); - for (const term of FORBIDDEN_2026_VOCABULARY) { - expect(serialized.toLowerCase()).not.toContain(term.toLowerCase()); - } - } - await close(); - }); - - it('the modern-direction denial mirrors it: a modern-classified request for a legacy-only method answers −32601', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - let id = 20; - for (const method of LEGACY_ONLY_METHODS) { - const response = await request({ jsonrpc: '2.0', id: (id += 1), method, params: { _meta: envelope() } }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - const error = (response as JSONRPCErrorResponse).error; - expect(error.code).toBe(-32_601); - expect(error.message).toBe('Method not found'); - } - await close(); - }); -}); - -describe('enveloped initialize on a dual-era instance (a valid modern claim wins over the legacy-handshake rule)', () => { - it('an initialize carrying a valid modern envelope claim answers a plain −32601 and is never served by the legacy handshake', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - const response = await request({ jsonrpc: '2.0', id: 30, method: 'initialize', params: { _meta: envelope() } }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - const error = (response as JSONRPCErrorResponse).error; - expect(error.code).toBe(-32_601); - expect(error.message).toBe('Method not found'); - expect(error.data).toBeUndefined(); - - // Nothing beyond the normal method-not-found shape leaks 2026 vocabulary. - const serialized = JSON.stringify({ error, id: null }); - for (const term of FORBIDDEN_2026_VOCABULARY) { - expect(serialized.toLowerCase()).not.toContain(term.toLowerCase()); - } - - // The legacy initialize path never ran: the initialize-scoped accessors stay unset. - expect(server.server.getNegotiatedProtocolVersion()).toBeUndefined(); - expect(server.server.getClientVersion()).toBeUndefined(); - - // An envelope-less initialize on the same connection keeps today's behavior: - // the legacy handshake is served exactly as before, with zero 2026 vocabulary. - const init = await request(initializeRequest(31)); - expect(isJSONRPCResultResponse(init)).toBe(true); - if (isJSONRPCResultResponse(init)) { - expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - expect(JSON.stringify(init)).not.toContain('resultType'); - expect(JSON.stringify(init)).not.toContain('2026'); - } - await close(); - }); - - it('an initialize with a malformed envelope claim keeps the legacy handshake', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - // The claim key is present but the envelope is incomplete — never a - // silent flip to the modern era; the legacy handshake serves it as before. - const response = await request({ - jsonrpc: '2.0', - id: 40, - method: 'initialize', - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: 'legacy-client', version: '1.0.0' }, - _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } - } - }); - expect(isJSONRPCResultResponse(response)).toBe(true); - if (isJSONRPCResultResponse(response)) { - expect((response.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - } - await close(); - }); - - it('an initialize whose valid envelope claim names a pre-2026 revision keeps the legacy handshake', async () => { - const server = buildServer({ eraSupport: 'dual-era' }); - const { request, close } = await wire(server); - - const response = await request({ - jsonrpc: '2.0', - id: 50, - method: 'initialize', - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: 'legacy-client', version: '1.0.0' }, - _meta: envelope({ [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }) - } - }); - expect(isJSONRPCResultResponse(response)).toBe(true); - if (isJSONRPCResultResponse(response)) { - expect((response.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - } - await close(); - }); -}); - -describe('Q10-L2: a hand-constructed server with the default eraSupport on 2025 traffic', () => { - it('serves a scripted 2025 session with the exact 2025 shapes and zero 2026 vocabulary on the wire', async () => { - const server = buildServer(); - const { request, notify, inbound, close } = await wire(server); - - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - if (isJSONRPCResultResponse(init)) { - expect(init.result).toEqual({ - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: 'dual-era-test-server', version: '1.0.0' }, - instructions: 'test instructions' - }); - } - await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); - - const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); - expect(isJSONRPCResultResponse(list)).toBe(true); - if (isJSONRPCResultResponse(list)) { - const tools = (list.result as { tools: Array> }).tools; - expect(tools).toHaveLength(1); - expect(tools[0]).toMatchObject({ name: 'echo', description: 'Echoes the input text' }); - expect(Object.keys(list.result as Record).sort()).toEqual(['tools']); - } - - const call = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'echo', arguments: { text: 'hi' } } }); - expect(isJSONRPCResultResponse(call)).toBe(true); - if (isJSONRPCResultResponse(call)) { - expect(call.result).toEqual({ content: [{ type: 'text', text: 'hi' }] }); - } - - const ping = await request({ jsonrpc: '2.0', id: 4, method: 'ping' }); - expect(isJSONRPCResultResponse(ping)).toBe(true); - if (isJSONRPCResultResponse(ping)) { - expect(ping.result).toEqual({}); - } - - // A default instance keeps answering server/discover with -32601, byte-identical to the deployed fleet. - const discover = await request({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: {} }); - expect(isJSONRPCErrorResponse(discover)).toBe(true); - if (isJSONRPCErrorResponse(discover)) { - expect(discover.error).toEqual({ code: -32_601, message: 'Method not found' }); - } - - // Nothing the server wrote on this 2025 session carries 2026 wire vocabulary. - const wireBytes = JSON.stringify(inbound); - expect(wireBytes).not.toContain('resultType'); - expect(wireBytes).not.toContain('2026'); - expect(wireBytes).not.toContain('io.modelcontextprotocol/'); - - await close(); - }); -}); diff --git a/packages/server/test/server/eraSupport.test.ts b/packages/server/test/server/eraSupport.test.ts deleted file mode 100644 index 78ff050cb7..0000000000 --- a/packages/server/test/server/eraSupport.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * `ServerOptions.eraSupport` — the stdio/long-lived-connection era opt-in: - * - * - default `'legacy'` for hand-constructed `Server`/`McpServer`: nothing - * 2026-era is registered or advertised, and a modern revision in - * `supportedProtocolVersions` without the declaration is a construction-time - * `TypeError` (never a silent behavior change). - * - `'dual-era'`: `server/discover` registered without any instance binding, - * modern revisions advertised, both eras served per message. - * - `'modern'`: strict 2026-only — envelope-less requests (including - * `initialize`) answer the unsupported-protocol-version error with the - * supported list; legacy-classified notifications are dropped. - * - TS-01 directionality: a modern-bound instance cannot emit server→client - * wire requests (typed local error); a dual-era instance serving the legacy - * leg still can, while a handler serving a 2026-classified request gets the - * same typed error from the ctx-related request path. - */ -import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - InMemoryTransport, - isJSONRPCErrorResponse, - isJSONRPCResultResponse, - LATEST_PROTOCOL_VERSION, - PROTOCOL_VERSION_META_KEY, - SdkError, - SdkErrorCode, - SUPPORTED_PROTOCOL_VERSIONS -} from '@modelcontextprotocol/core'; -import { describe, expect, it } from 'vitest'; - -import { McpServer } from '../../src/server/mcp.js'; -import { Server } from '../../src/server/server.js'; - -const MODERN = '2026-07-28'; -const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS]; - -const envelope = (overrides?: Record) => ({ - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'era-test-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {}, - ...overrides -}); - -const initializeRequest = (id: number, requestedVersion = LATEST_PROTOCOL_VERSION): JSONRPCRequest => ({ - jsonrpc: '2.0', - id, - method: 'initialize', - params: { - protocolVersion: requestedVersion, - capabilities: { sampling: {} }, - clientInfo: { name: 'legacy-client', version: '1.0.0' } - } -}); - -interface Connectable { - connect(transport: InstanceType): Promise; - close(): Promise; -} - -/** Wires a server to one long-lived in-memory connection and returns request/notify drivers. */ -async function wireServer(server: Connectable) { - const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); - const inbound: JSONRPCMessage[] = []; - const waiters = new Map void>(); - peerTx.onmessage = message => { - inbound.push(message); - const id = (message as { id?: string | number }).id; - const waiter = id === undefined ? undefined : waiters.get(id); - if (id !== undefined && waiter) { - waiters.delete(id); - waiter(message); - } - }; - await server.connect(serverTx); - await peerTx.start(); - - const request = (message: JSONRPCRequest): Promise => - new Promise(resolve => { - waiters.set(message.id, resolve); - void peerTx.send(message); - }); - const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); - const flush = () => new Promise(resolve => setTimeout(resolve, 10)); - return { request, notify, flush, inbound, peerTx, close: () => server.close() }; -} - -describe('construction-time guard (default eraSupport is legacy)', () => { - it('throws a TypeError when supportedProtocolVersions carries a modern revision on a default instance', () => { - expect(() => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS })).toThrow( - TypeError - ); - expect(() => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS })).toThrow( - /eraSupport/ - ); - }); - - it('throws for McpServer too (options are forwarded)', () => { - expect(() => new McpServer({ name: 't', version: '1' }, { supportedProtocolVersions: [MODERN] })).toThrow(TypeError); - }); - - it('does not throw when the modern revision is accompanied by a dual-era or modern declaration', () => { - expect( - () => - new Server( - { name: 't', version: '1' }, - { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } - ) - ).not.toThrow(); - expect( - () => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: [MODERN], eraSupport: 'modern' }) - ).not.toThrow(); - }); - - it('a default legacy-only construction stays exactly as before (no throw, no discover handler)', async () => { - const server = new Server({ name: 't', version: '1' }, { capabilities: {} }); - const { request, close } = await wireServer(server); - const response = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - if (isJSONRPCErrorResponse(response)) { - expect(response.error.code).toBe(-32_601); - } - await close(); - }); -}); - -describe("DV-30: server/discover is registered only when eraSupport !== 'legacy'", () => { - it('a dual-era server serves discover with no instance binding and advertises only modern revisions', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); - const { request, close } = await wireServer(server); - - const response = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(response)).toBe(true); - if (isJSONRPCResultResponse(response)) { - const result = response.result as { supportedVersions?: string[]; resultType?: string }; - expect(result.supportedVersions).toEqual([MODERN]); - // Served on the modern era: the wire result carries the 2026 result discriminator. - expect(result.resultType).toBe('complete'); - } - await close(); - }); - - it('the served modern revisions are added to the supported list without mutating the shared default constant', () => { - const before = [...SUPPORTED_PROTOCOL_VERSIONS]; - const server = new Server({ name: 'dual', version: '1' }, { capabilities: {}, eraSupport: 'dual-era' }); - expect(SUPPORTED_PROTOCOL_VERSIONS).toEqual(before); - expect(server).toBeDefined(); - }); -}); - -describe("DV-31: strict 'modern' on a long-lived connection", () => { - async function wireModernServer() { - const server = new Server({ name: 'strict', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'modern' }); - server.setRequestHandler('tools/list', () => ({ tools: [] })); - return { server, ...(await wireServer(server)) }; - } - - it('an envelope-less non-initialize request answers −32004 with the supported list', async () => { - const { request, close } = await wireModernServer(); - const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - if (isJSONRPCErrorResponse(response)) { - // The envelope-less request on a modern-only instance answers the - // unsupported-protocol-version error with the supported list (the - // HTTP entry's header/body mismatch cells use −32001 instead; there - // is no header layer on a long-lived connection). - expect(response.error.code).toBe(-32_004); - const data = response.error.data as { supported?: string[]; requested?: string }; - // The strict instance serves only modern revisions, so the supported - // list it advertises names only those (never the legacy defaults). - expect(data.supported).toEqual([MODERN]); - expect(typeof data.requested).toBe('string'); - } - await close(); - }); - - it('an envelope-less initialize answers −32004 with the supported list (never a legacy handshake)', async () => { - const { request, close } = await wireModernServer(); - const response = await request(initializeRequest(2)); - expect(isJSONRPCErrorResponse(response)).toBe(true); - if (isJSONRPCErrorResponse(response)) { - expect(response.error.code).toBe(-32_004); - expect((response.error.data as { supported?: string[] }).supported).toEqual([MODERN]); - expect((response.error.data as { requested?: string }).requested).toBe(LATEST_PROTOCOL_VERSION); - } - await close(); - }); - - it('an initialize carrying a valid modern envelope claim answers a plain −32601 (the claim wins over the legacy-handshake rule)', async () => { - const { request, close } = await wireModernServer(); - const response = await request({ jsonrpc: '2.0', id: 5, method: 'initialize', params: { _meta: envelope() } }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - if (isJSONRPCErrorResponse(response)) { - // Classified by its valid modern claim, the request is served on the - // modern era, where `initialize` is answered like every other method - // that era does not define — never with the version error reserved - // for envelope-less requests. - expect(response.error.code).toBe(-32_601); - expect(response.error.message).toBe('Method not found'); - expect(response.error.data).toBeUndefined(); - } - await close(); - }); - - it('a legacy-classified notification is dropped without a response', async () => { - const { notify, flush, inbound, close } = await wireModernServer(); - await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); - await flush(); - expect(inbound).toHaveLength(0); - await close(); - }); - - it('an enveloped modern request is served', async () => { - const { request, close } = await wireModernServer(); - const response = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(response)).toBe(true); - if (isJSONRPCResultResponse(response)) { - expect((response.result as { tools?: unknown[] }).tools).toEqual([]); - expect((response.result as { resultType?: string }).resultType).toBe('complete'); - } - await close(); - }); - - it('server/discover advertises only modern revisions', async () => { - const { request, close } = await wireModernServer(); - const response = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(response)).toBe(true); - if (isJSONRPCResultResponse(response)) { - expect((response.result as { supportedVersions?: string[] }).supportedVersions).toEqual([MODERN]); - } - await close(); - }); - - it('a mixed legacy+modern supported list is reduced to its modern subset at construction', async () => { - const server = new Server( - { name: 'strict', version: '1' }, - { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'modern' } - ); - const { request, close } = await wireServer(server); - - // The unsupported-protocol-version handoff names only the modern - // revisions: the legacy entries the consumer passed are never served - // by a strict instance, so they are not advertised either. - const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); - expect(isJSONRPCErrorResponse(response)).toBe(true); - if (isJSONRPCErrorResponse(response)) { - expect((response.error.data as { supported?: string[] }).supported).toEqual([MODERN]); - } - await close(); - }); -}); - -describe('TS-01 directionality (era-keyed direction enforcement)', () => { - it('a strict-modern instance cannot emit server→client wire requests: typed local error, nothing reaches the transport', async () => { - const server = new Server({ name: 'strict', version: '1' }, { capabilities: {}, eraSupport: 'modern' }); - const { inbound, flush, close } = await wireServer(server); - - await expect( - server.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }) - ).rejects.toThrow(/not supported by the negotiated protocol version/); - await flush(); - expect(inbound).toHaveLength(0); - await close(); - }); - - it('a dual-era instance serving the legacy leg still emits server→client requests (permitted per the message era)', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: {}, eraSupport: 'dual-era' }); - const { request, inbound, flush, close } = await wireServer(server); - - // Legacy leg: the 2025 client initializes and declares sampling support. - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - - // The server-initiated sampling request is legal on the legacy leg and reaches the wire. - const pending = server.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }); - pending.catch(() => { - // The peer never answers; the request is torn down with the connection below. - }); - await flush(); - expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(true); - await close(); - }); - - it('a handler serving a modern-classified request gets the typed error from the ctx sampling helper; nothing reaches the transport', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); - let captured: unknown; - server.setRequestHandler('tools/list', async (_request, ctx) => { - try { - await ctx.mcpReq.requestSampling({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }); - } catch (error) { - captured = error; - } - return { tools: [] }; - }); - const { request, inbound, flush, close } = await wireServer(server); - - const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(response)).toBe(true); - await flush(); - - expect(captured).toBeInstanceOf(SdkError); - expect((captured as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); - expect((captured as SdkError).message).toMatch(/not available on protocol revision 2026-07-28/); - expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(false); - await close(); - }); - - it('a raw ctx server→client request send while serving a modern-classified request is rejected the same way', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); - let captured: unknown; - server.setRequestHandler('tools/list', async (_request, ctx) => { - try { - await ctx.mcpReq.send({ method: 'roots/list' }); - } catch (error) { - captured = error; - } - return { tools: [] }; - }); - const { request, inbound, flush, close } = await wireServer(server); - - const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); - expect(isJSONRPCResultResponse(response)).toBe(true); - await flush(); - - expect(captured).toBeInstanceOf(SdkError); - expect((captured as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); - expect(inbound.some(message => (message as JSONRPCRequest).method === 'roots/list')).toBe(false); - await close(); - }); - - it('the same ctx sampling helper on a legacy-classified request still reaches the wire (permitted per the message era)', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); - server.setRequestHandler('tools/list', (_request, ctx) => { - const pending = ctx.mcpReq.requestSampling({ - messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], - maxTokens: 1 - }); - pending.catch(() => { - // The peer never answers; the request is torn down with the connection below. - }); - return { tools: [] }; - }); - const { request, inbound, flush, close } = await wireServer(server); - - // Legacy leg: the 2025 client initializes and declares sampling support. - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - - const response = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); - expect(isJSONRPCResultResponse(response)).toBe(true); - await flush(); - - expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(true); - await close(); - }); -}); - -describe('accessor split on long-lived dual-era instances', () => { - it('getClientCapabilities/getClientVersion/getNegotiatedProtocolVersion keep initialize-scoped semantics; modern envelopes never backfill them', async () => { - const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); - server.setRequestHandler('tools/list', () => ({ tools: [] })); - const { request, close } = await wireServer(server); - - // Legacy handshake populates the initialize-scoped accessors. - const init = await request(initializeRequest(1)); - expect(isJSONRPCResultResponse(init)).toBe(true); - expect(server.getClientVersion()).toEqual({ name: 'legacy-client', version: '1.0.0' }); - expect(server.getClientCapabilities()).toEqual({ sampling: {} }); - expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); - - // A modern message carrying a different client identity in its envelope - // is served, but never backfills the instance-level accessors (per-message - // identity is read from the per-request context, not instance state). - const modern = await request({ - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - params: { - _meta: envelope({ [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '9.9.9' } }) - } - }); - expect(isJSONRPCResultResponse(modern)).toBe(true); - expect(server.getClientVersion()).toEqual({ name: 'legacy-client', version: '1.0.0' }); - expect(server.getClientCapabilities()).toEqual({ sampling: {} }); - expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); - - await close(); - }); -}); diff --git a/packages/server/test/server/legacyDefaultServing.test.ts b/packages/server/test/server/legacyDefaultServing.test.ts new file mode 100644 index 0000000000..877349894c --- /dev/null +++ b/packages/server/test/server/legacyDefaultServing.test.ts @@ -0,0 +1,113 @@ +/** + * Q10-L2 golden pin: a hand-constructed `McpServer` connected to a long-lived + * transport (the shape of every existing stdio server) serves a scripted 2025 + * session with today's exact result shapes and zero 2026 vocabulary on the + * wire — and keeps answering `server/discover` with `-32601`, byte-identical + * to the deployed fleet. Hand-constructed instances serve only the 2025 era; + * serving the 2026-07-28 revision on stdio goes through the `serveStdio` + * entry (covered in `serveStdio.test.ts`). + */ +import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { InMemoryTransport, isJSONRPCErrorResponse, isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp.js'; + +function buildServer() { + const server = new McpServer( + { name: 'legacy-default-test-server', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'test instructions' } + ); + server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return server; +} + +async function wire(server: McpServer) { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + const waiters = new Map void>(); + peerTx.onmessage = message => { + inbound.push(message); + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + await server.connect(serverTx); + await peerTx.start(); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); + return { request, notify, inbound, close: () => server.close() }; +} + +const initializeRequest = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +describe('Q10-L2: a hand-constructed server on 2025 traffic', () => { + it('serves a scripted 2025 session with the exact 2025 shapes and zero 2026 vocabulary on the wire', async () => { + const server = buildServer(); + const { request, notify, inbound, close } = await wire(server); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect(init.result).toEqual({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'legacy-default-test-server', version: '1.0.0' }, + instructions: 'test instructions' + }); + } + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + const tools = (list.result as { tools: Array> }).tools; + expect(tools).toHaveLength(1); + expect(tools[0]).toMatchObject({ name: 'echo', description: 'Echoes the input text' }); + expect(Object.keys(list.result as Record).sort()).toEqual(['tools']); + } + + const call = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'echo', arguments: { text: 'hi' } } }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect(call.result).toEqual({ content: [{ type: 'text', text: 'hi' }] }); + } + + const ping = await request({ jsonrpc: '2.0', id: 4, method: 'ping' }); + expect(isJSONRPCResultResponse(ping)).toBe(true); + if (isJSONRPCResultResponse(ping)) { + expect(ping.result).toEqual({}); + } + + // A default instance keeps answering server/discover with -32601, byte-identical to the deployed fleet. + const discover = await request({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: {} }); + expect(isJSONRPCErrorResponse(discover)).toBe(true); + if (isJSONRPCErrorResponse(discover)) { + expect(discover.error).toEqual({ code: -32_601, message: 'Method not found' }); + } + + // Nothing the server wrote on this 2025 session carries 2026 wire vocabulary. + const wireBytes = JSON.stringify(inbound); + expect(wireBytes).not.toContain('resultType'); + expect(wireBytes).not.toContain('2026'); + expect(wireBytes).not.toContain('io.modelcontextprotocol/'); + + await close(); + }); +}); diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts new file mode 100644 index 0000000000..0ba6c3ecf5 --- /dev/null +++ b/packages/server/test/server/serveStdio.test.ts @@ -0,0 +1,778 @@ +/** + * `serveStdio` — the connection-pinned stdio entry: + * + * - the opening exchange selects the era exactly once; ONE factory instance + * is pinned for the connection lifetime and serves only that era; + * - a legacy opening (`initialize`, or any claim-less message) pins a 2025 + * instance that serves the session exactly as a hand-wired stdio server + * does today (zero 2026 vocabulary on the wire — the per-connection leak + * test); + * - a valid modern envelope opening pins a 2026-07-28 instance (era-written + * by the entry, modern-only handlers installed); + * - a `server/discover` probe is answered without pinning; the next message + * either pins the modern era or falls back to a fresh legacy instance + * (probe instance discarded) when the client returns to `initialize`; + * - once the modern era is pinned, a late claim-less `initialize` is answered + * with the unsupported-protocol-version error naming the supported + * revisions; + * - `legacy: 'reject'` answers legacy openings with the same error and never + * pins a legacy instance; + * - malformed and unsupported envelope claims are answered by the entry, + * consistent with the HTTP entry's treatment, without pinning. + */ +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + MessageExtraInfo, + Transport +} from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { McpServerFactory } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; +import type { ServeStdioOptions } from '../../src/server/serveStdio.js'; +import { serveStdio } from '../../src/server/serveStdio.js'; + +const MODERN = '2026-07-28'; + +/** 2026-era vocabulary that must never leak onto a connection pinned to the 2025 era. */ +const FORBIDDEN_2026_VOCABULARY = ['2026', 'discover', 'envelope', 'modern', 'era', 'resultType', 'io.modelcontextprotocol']; + +const envelope = (overrides?: Record) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'serve-stdio-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {}, + ...overrides +}); + +const initializeRequest = (id: number | string, requestedVersion = LATEST_PROTOCOL_VERSION): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: requestedVersion, + capabilities: {}, + clientInfo: { name: 'legacy-client', version: '1.0.0' } + } +}); + +/** A factory that records every construction (era + product) and registers one echo tool. */ +function trackingFactory() { + const eras: Array<'legacy' | 'modern'> = []; + const closed: boolean[] = []; + const factory = (ctx: { era: 'legacy' | 'modern' }) => { + const index = eras.length; + eras.push(ctx.era); + closed.push(false); + const server = new McpServer( + { name: 'serve-stdio-test-server', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'serve-stdio test instructions' } + ); + server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + server.server.onclose = () => { + closed[index] = true; + }; + return server; + }; + return { factory, eras, closed }; +} + +/** Boots the entry on one side of an in-memory pair with the given factory and returns raw drivers for the peer side. */ +async function startEntryWith(factory: McpServerFactory, options?: Omit) { + const [peerTx, wireTx] = InMemoryTransport.createLinkedPair(); + + const inbound: JSONRPCMessage[] = []; + const waiters = new Map void>(); + peerTx.onmessage = message => { + inbound.push(message); + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + await peerTx.start(); + + const errors: Error[] = []; + const handle = serveStdio(factory, { transport: wireTx, onerror: error => void errors.push(error), ...options }); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); + const flush = () => new Promise(resolve => setTimeout(resolve, 20)); + + return { handle, request, notify, flush, inbound, errors, peerTx }; +} + +/** Boots the entry with a fresh tracking factory (the default harness for most tests). */ +async function startEntry(options?: Omit) { + const { factory, eras, closed } = trackingFactory(); + return { ...(await startEntryWith(factory, options)), eras, closed }; +} + +describe('legacy opening (default legacy: serve)', () => { + it('pins one 2025-era instance for the connection and serves it exactly like a hand-wired stdio server', async () => { + const { handle, request, notify, inbound, eras } = await startEntry(); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect(init.result).toEqual({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'serve-stdio-test-server', version: '1.0.0' }, + instructions: 'serve-stdio test instructions' + }); + } + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + expect((list.result as { tools: Array<{ name: string }> }).tools.map(tool => tool.name)).toEqual(['echo']); + expect(Object.keys(list.result as Record).sort()).toEqual(['tools']); + } + + const call = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'echo', arguments: { text: 'hi' } } }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect(call.result).toEqual({ content: [{ type: 'text', text: 'hi' }] }); + } + + // The era decision happened exactly once: one legacy instance, no probe instance. + expect(eras).toEqual(['legacy']); + + // Per-connection leak test: a claim-less server/discover on this + // 2025-pinned connection answers the same plain -32601 a deployed 2025 + // server answers, with zero 2026 vocabulary anywhere in the response. + const gate = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} }); + expect(isJSONRPCErrorResponse(gate)).toBe(true); + if (isJSONRPCErrorResponse(gate)) { + expect(gate.error).toEqual({ code: -32_601, message: 'Method not found' }); + } + + // Nothing the entry or the instance wrote on this connection carries 2026 wire vocabulary. + const wireBytes = JSON.stringify(inbound).toLowerCase(); + for (const term of FORBIDDEN_2026_VOCABULARY) { + expect(wireBytes).not.toContain(term.toLowerCase()); + } + + await handle.close(); + }); + + it('a claim-less non-initialize opening also pins the legacy era', async () => { + const { handle, request, eras } = await startEntry(); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + expect(eras).toEqual(['legacy']); + + await handle.close(); + }); +}); + +describe('modern opening', () => { + it('a valid enveloped request pins one era-written 2026-07-28 instance', async () => { + const { handle, request, eras } = await startEntry(); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + const result = list.result as { tools: Array<{ name: string }>; resultType?: string }; + expect(result.tools.map(tool => tool.name)).toEqual(['echo']); + expect(result.resultType).toBe('complete'); + } + expect(eras).toEqual(['modern']); + + const call = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'modern leg' }]); + } + + await handle.close(); + }); + + it('an enveloped initialize is classified by its valid modern claim and answered with a plain -32601', async () => { + const { handle, request, eras } = await startEntry(); + + const response = await request({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + expect(response.error.message).toBe('Method not found'); + expect(response.error.data).toBeUndefined(); + } + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('once the modern era is pinned, a late claim-less initialize answers -32004 naming the supported revisions', async () => { + const { handle, request } = await startEntry(); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + + const init = await request(initializeRequest(2)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_004); + const data = init.error.data as { supported?: string[]; requested?: string }; + expect(data.supported).toContain(MODERN); + expect(data.requested).toBe(LATEST_PROTOCOL_VERSION); + } + + await handle.close(); + }); +}); + +describe('server/discover probe window', () => { + it('answers the probe from an optimistically built modern instance and pins modern when the client continues with the envelope', async () => { + const { handle, request, eras } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + if (isJSONRPCResultResponse(discover)) { + const result = discover.result as { supportedVersions?: string[]; resultType?: string }; + expect(result.supportedVersions).toEqual([MODERN]); + expect(result.resultType).toBe('complete'); + } + + const call = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'after probe' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'after probe' }]); + } + + // The probe instance IS the pinned instance: the factory ran once. + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('discover followed by initialize falls back to a fresh legacy instance and discards the probe instance', async () => { + const { handle, request, eras, closed } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + + // The client found no mutually supported modern revision and falls + // back to the 2025 handshake on the same connection. + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + + // The optimistic modern instance was discarded; the legacy session is + // served end to end by the second (legacy) instance. + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + + const list = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + expect(JSON.stringify(list)).not.toContain('resultType'); + } + + await handle.close(); + }); + + it('answers the probe even when the fallback initialize is pipelined immediately behind it', async () => { + const { handle, request, flush, inbound, errors, eras } = await startEntry(); + + // The client does not wait for the DiscoverResult before falling back: + // both messages are on the wire back to back. The probe must still be + // answered (never silently dropped) and the legacy session served. + const discoverPromise = request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + const initPromise = request(initializeRequest(2)); + + const [discover, init] = await Promise.all([discoverPromise, initPromise]); + expect(isJSONRPCResultResponse(discover)).toBe(true); + if (isJSONRPCResultResponse(discover)) { + expect((discover.result as { supportedVersions?: string[] }).supportedVersions).toEqual([MODERN]); + } + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + // The probe answer reached the wire before the fallback's handshake answer. + expect(inbound.indexOf(discover)).toBeLessThan(inbound.indexOf(init)); + expect(eras).toEqual(['modern', 'legacy']); + + // The legacy session continues normally and nothing was dropped or reported. + const list = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + await flush(); + expect(errors).toEqual([]); + + await handle.close(); + }); + + it('a repeated server/discover probe is answered by the same probe instance and a later initialize still falls back to legacy', async () => { + const { handle, request, eras, closed } = await startEntry(); + + const first = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(first)).toBe(true); + + const second = await request({ jsonrpc: '2.0', id: 'probe-2', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(second)).toBe(true); + if (isJSONRPCResultResponse(second)) { + expect((second.result as { supportedVersions?: string[] }).supportedVersions).toEqual([MODERN]); + } + + // Both probes were answered by the single optimistic instance; the + // connection is still inside the negotiation window. + expect(eras).toEqual(['modern']); + + // The fallback handshake is still served by a fresh legacy instance. + const init = await request(initializeRequest(3)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + + await handle.close(); + }); + + it('an enveloped notification during the probe window does not pin the era and a later initialize still falls back to legacy', async () => { + const { handle, request, notify, flush, eras, closed, errors } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + + // The client cancels its probe (for example on a local timeout) with + // an enveloped notification before falling back to the 2025 + // handshake. The notification is delivered to the probe instance but + // does not commit the connection to the modern era. + await notify({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 'probe-1', reason: 'probe timed out', _meta: envelope() } + }); + await flush(); + + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + + // The fallback handshake was served by a fresh legacy instance and + // the probe instance was discarded; nothing was reported as dropped. + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + expect(errors).toEqual([]); + + await handle.close(); + }); + + it('an enveloped non-discover request after the probe still pins the modern era', async () => { + const { handle, request, eras } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + + const call = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'commit' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + + // The enveloped request committed the connection: a later claim-less + // initialize is rejected instead of falling back to a legacy instance. + const init = await request(initializeRequest(3)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_004); + } + // The probe instance is the pinned instance: the factory ran exactly once. + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('a repeated server/discover probe followed by an enveloped request pins the modern era', async () => { + const { handle, request, eras } = await startEntry(); + + const first = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(first)).toBe(true); + + const second = await request({ jsonrpc: '2.0', id: 'probe-2', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(second)).toBe(true); + + const call = await request({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'after repeated probe' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'after repeated probe' }]); + } + + // The probe instance is the pinned instance: the factory ran exactly once. + expect(eras).toEqual(['modern']); + + await handle.close(); + }); +}); + +describe("legacy: 'reject'", () => { + it('answers a legacy opening with -32004 naming the supported modern revisions and never pins a legacy instance', async () => { + const { handle, request, eras } = await startEntry({ legacy: 'reject' }); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_004); + const data = init.error.data as { supported?: string[]; requested?: string }; + expect(data.supported).toContain(MODERN); + expect(data.requested).toBe(LATEST_PROTOCOL_VERSION); + } + expect(eras).toEqual([]); + + // A modern opening on the same connection is still served afterwards. + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('drops a claim-less notification without a response', async () => { + const { handle, notify, flush, inbound, eras } = await startEntry({ legacy: 'reject' }); + + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await flush(); + + expect(inbound).toHaveLength(0); + expect(eras).toEqual([]); + + await handle.close(); + }); +}); + +describe('malformed and unsupported envelope claims (entry-answered, never pinned)', () => { + it('a present claim with a malformed envelope answers -32602 naming the envelope problem', async () => { + const { handle, request, eras } = await startEntry(); + + const response = await request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } } + }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_602); + expect(response.error.message).toContain('Invalid _meta envelope'); + } + expect(eras).toEqual([]); + + // The connection is not pinned by the rejected opening: a valid + // modern opening afterwards is served normally. + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('a valid claim naming an unsupported revision answers -32004 with the supported list', async () => { + const { handle, request, eras } = await startEntry(); + + const response = await request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: envelope({ [PROTOCOL_VERSION_META_KEY]: '2099-01-01' }) } + }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_004); + const data = (response as JSONRPCErrorResponse).error.data as { supported?: string[]; requested?: string }; + expect(data.supported).toContain(MODERN); + expect(data.requested).toBe('2099-01-01'); + } + expect(eras).toEqual([]); + + await handle.close(); + }); +}); + +describe('factory or connect failure during the opening exchange (entry-answered, never pinned)', () => { + it('answers a legacy opening with -32603 when the factory throws, reports the error, and leaves the connection unpinned', async () => { + const { factory: workingFactory, eras } = trackingFactory(); + let failures = 1; + const factory: McpServerFactory = ctx => { + if (failures > 0) { + failures -= 1; + throw new Error('factory failed to build an instance'); + } + return workingFactory(ctx); + }; + const { handle, request, flush, errors } = await startEntryWith(factory); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_603); + expect(init.error.message).toBe('Internal server error'); + } + await flush(); + expect(errors.some(error => error.message.includes('factory failed to build an instance'))).toBe(true); + expect(eras).toEqual([]); + + // The failed opening did not pin the connection: a retried handshake + // on the same connection is served by a fresh legacy instance. + const retry = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(retry)).toBe(true); + expect(eras).toEqual(['legacy']); + + await handle.close(); + }); + + it('answers a modern opening with -32603 when connecting the instance fails and leaves the connection unpinned', async () => { + const { factory: workingFactory, eras } = trackingFactory(); + let failures = 1; + const factory: McpServerFactory = ctx => { + const product = workingFactory(ctx); + if (failures > 0) { + failures -= 1; + product.connect = () => Promise.reject(new Error('instance connect failed')); + } + return product; + }; + const { handle, request, flush, errors } = await startEntryWith(factory); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(list)).toBe(true); + if (isJSONRPCErrorResponse(list)) { + expect(list.error.code).toBe(-32_603); + expect(list.error.message).toBe('Internal server error'); + } + await flush(); + expect(errors.some(error => error.message.includes('instance connect failed'))).toBe(true); + // The factory ran but nothing was pinned: the next modern opening is + // served by a freshly connected instance. + expect(eras).toEqual(['modern']); + + const retry = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(retry)).toBe(true); + expect(eras).toEqual(['modern', 'modern']); + + await handle.close(); + }); + + it('answers a server/discover probe with -32603 when the factory rejects and keeps the negotiation window open', async () => { + const { factory: workingFactory, eras } = trackingFactory(); + let failures = 1; + const factory: McpServerFactory = ctx => { + if (failures > 0) { + failures -= 1; + return Promise.reject(new Error('factory failed to build an instance')); + } + return workingFactory(ctx); + }; + const { handle, request, flush, errors } = await startEntryWith(factory); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(discover)).toBe(true); + if (isJSONRPCErrorResponse(discover)) { + expect(discover.error.code).toBe(-32_603); + expect(discover.error.message).toBe('Internal server error'); + } + await flush(); + expect(errors.some(error => error.message.includes('factory failed to build an instance'))).toBe(true); + expect(eras).toEqual([]); + + // The failed probe did not pin anything: the connection is still in + // the negotiation window and a fallback handshake is served normally. + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + expect(eras).toEqual(['legacy']); + + await handle.close(); + }); +}); + +describe('a close racing the opening factory', () => { + /** + * A factory that suspends until released and exposes what happens to its + * product afterwards: whether it was closed, and every message that is + * delivered to it after it has been connected. + */ + function gatedObservableFactory() { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + let entered!: () => void; + const constructionStarted = new Promise(resolve => { + entered = resolve; + }); + const eras: Array<'legacy' | 'modern'> = []; + const productClosed: boolean[] = []; + const delivered: JSONRPCMessage[] = []; + const factory: McpServerFactory = async ctx => { + const index = eras.length; + eras.push(ctx.era); + productClosed.push(false); + entered(); + await gate; + const server = new McpServer({ name: 'serve-stdio-test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.server.onclose = () => { + productClosed[index] = true; + }; + const realConnect = server.connect.bind(server); + server.connect = async (transport: Transport) => { + await realConnect(transport); + const forward = transport.onmessage; + transport.onmessage = (message: JSONRPCMessage, extra?: MessageExtraInfo) => { + delivered.push(message); + forward?.(message, extra); + }; + }; + return server; + }; + return { factory, constructionStarted, release, eras, productClosed, delivered }; + } + + it('handle.close() during the legacy factory build stays closed: the late instance is closed and never delivered to', async () => { + const { factory, constructionStarted, release, eras, productClosed, delivered } = gatedObservableFactory(); + const { handle, flush, inbound, peerTx } = await startEntryWith(factory); + + // The opening handshake arrives and the entry starts building the + // legacy instance; the connection is closed while the factory is + // still mid-construction. + void peerTx.send(initializeRequest(1)); + await constructionStarted; + await handle.close(); + + // The factory resolves only after the connection is gone. + release(); + await flush(); + + // The connection stays closed: the late-resolved instance is closed, + // the opening message is never delivered to it, nothing further + // reaches the wire, and no other instance is built. + expect(eras).toEqual(['legacy']); + expect(productClosed).toEqual([true]); + expect(delivered).toEqual([]); + expect(inbound).toEqual([]); + }); + + it('handle.close() during the probe-instance build does not resurrect the negotiation window', async () => { + const { factory, constructionStarted, release, eras, productClosed, delivered } = gatedObservableFactory(); + const { handle, flush, inbound, peerTx } = await startEntryWith(factory); + + void peerTx.send({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + await constructionStarted; + await handle.close(); + + release(); + await flush(); + + expect(eras).toEqual(['modern']); + expect(productClosed).toEqual([true]); + expect(delivered).toEqual([]); + expect(inbound).toEqual([]); + }); +}); + +describe('outbound era gate on a modern-pinned connection', () => { + it('a handler calling ctx.mcpReq.requestSampling gets the typed era error locally, with zero sampling wire traffic', async () => { + let observed: unknown; + const factory: McpServerFactory = () => { + const server = new McpServer({ name: 'serve-stdio-test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('sample', { description: 'Tries to request sampling', inputSchema: z.object({}) }, async (_args, ctx) => { + try { + await ctx.mcpReq.requestSampling({ messages: [], maxTokens: 1 }); + } catch (error) { + observed = error; + } + return { content: [{ type: 'text', text: 'handled locally' }] }; + }); + return server; + }; + const { handle, request, inbound } = await startEntryWith(factory); + + const call = await request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'sample', arguments: {}, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'handled locally' }]); + } + + // The outbound era gate fired locally with the typed error… + expect(observed).toBeInstanceOf(SdkError); + expect((observed as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + // …and nothing beyond the tool-call answer ever reached the wire: no + // sampling/createMessage request was written to the client. + expect(inbound).toEqual([call]); + + await handle.close(); + }); +}); + +describe('teardown', () => { + it('handle.close() closes the pinned instance and the wire transport', async () => { + const { handle, request, closed, peerTx } = await startEntry(); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + + let peerClosed = false; + peerTx.onclose = () => { + peerClosed = true; + }; + + await handle.close(); + expect(closed[0]).toBe(true); + expect(peerClosed).toBe(true); + }); +}); diff --git a/test/e2e/fixtures/dual-era-stdio-server.ts b/test/e2e/fixtures/dual-era-stdio-server.ts index b99b9763a9..31cf9b7e22 100644 --- a/test/e2e/fixtures/dual-era-stdio-server.ts +++ b/test/e2e/fixtures/dual-era-stdio-server.ts @@ -1,29 +1,28 @@ /** * Runnable dual-era stdio MCP server fixture for the dual-era stdio e2e cells. * - * `eraSupport: 'dual-era'` is the single declared act on an otherwise ordinary - * hand-constructed McpServer connected to the unchanged StdioServerTransport. + * The connection-pinned `serveStdio` entry over an ordinary `McpServer` + * factory: the client's opening exchange selects the era for the connection + * (a 2025 `initialize` handshake or 2026-07-28 per-request envelope traffic + * negotiated via `server/discover`), and one factory instance serves it. * Spawned as a real child process (via tsx) by * test/e2e/scenarios/stdio-dual-era.test.ts; exits when its stdin reaches EOF. */ import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import { z } from 'zod/v4'; -const server = new McpServer( - { name: 'dual-era-stdio-e2e-fixture', version: '1.0.0' }, - { capabilities: { tools: {} }, eraSupport: 'dual-era' } -); - -server.registerTool( - 'echo', - { - description: 'Echoes the input text back as a text content block.', - inputSchema: z.object({ text: z.string() }) - }, - ({ text }) => ({ content: [{ type: 'text', text }] }) -); - -await server.connect(new StdioServerTransport()); +serveStdio(() => { + const server = new McpServer({ name: 'dual-era-stdio-e2e-fixture', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool( + 'echo', + { + description: 'Echoes the input text back as a text content block.', + inputSchema: z.object({ text: z.string() }) + }, + ({ text }) => ({ content: [{ type: 'text', text }] }) + ); + return server; +}); process.stderr.write('[dual-era-stdio-server] ready\n'); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 71b2462633..b526e12749 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2267,7 +2267,7 @@ export const REQUIREMENTS: Record = { note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. The allowed-host control asserts initialize semantics per spec version: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' }, - // v2 features: dual-era serving (createMcpHandler entry, eraSupport stdio, result stamping) + // v2 features: dual-era serving (createMcpHandler entry, serveStdio stdio entry, result stamping) 'typescript:hosting:entry:dual-era-one-factory': { source: 'sdk', @@ -2342,9 +2342,9 @@ export const REQUIREMENTS: Record = { 'typescript:transport:stdio:dual-era-serving': { source: 'sdk', behavior: - 'A hand-constructed stdio server declaring eraSupport "dual-era" (transport line unchanged) serves a plain 2025 client via initialize and an auto-negotiating client on 2026-07-28 via server/discover, over a real child-process pipe.', + 'A stdio server hosted by the connection-pinned serveStdio entry serves a plain 2025 client via initialize and an auto-negotiating client on 2026-07-28 via server/discover, each on its own connection against the same factory, over a real child-process pipe.', transports: ['stdio'], - note: 'Dual-era stdio serving is exercised against a real spawned child process (fixtures/dual-era-stdio-server.ts), so the matrix transport arg is ignored and the requirement lists stdio only; the spec-version axis selects which client drives the cell.' + note: 'Dual-era stdio serving is exercised against a real spawned child process (fixtures/dual-era-stdio-server.ts), so the matrix transport arg is ignored and the requirement lists stdio only; the spec-version axis selects which client opens the connection.' }, 'custom-methods:server-handler:roundtrip': { source: 'sdk', diff --git a/test/e2e/scenarios/stdio-dual-era.test.ts b/test/e2e/scenarios/stdio-dual-era.test.ts index 46a2406ea1..319e85cf4c 100644 --- a/test/e2e/scenarios/stdio-dual-era.test.ts +++ b/test/e2e/scenarios/stdio-dual-era.test.ts @@ -3,12 +3,14 @@ * * Like the other transport:stdio scenarios these do not use `wire()`: each * body spawns the dual-era fixture server in - * `fixtures/dual-era-stdio-server.ts` (eraSupport: 'dual-era', unchanged - * StdioServerTransport) as a real child process via {@link StdioClientTransport}. - * The matrix `transport` arg is ignored (the requirement lists - * `transports: ['stdio']`); the spec-version axis selects which client drives - * the cell — a plain 2025 client over `initialize`, or the auto-negotiating - * client reaching 2026-07-28 over `server/discover` on the same kind of pipe. + * `fixtures/dual-era-stdio-server.ts` (the connection-pinned `serveStdio` + * entry over an ordinary McpServer factory) as a real child process via + * {@link StdioClientTransport}. The matrix `transport` arg is ignored (the + * requirement lists `transports: ['stdio']`); the spec-version axis selects + * which client opens the connection — a plain 2025 client over `initialize`, + * or the auto-negotiating client reaching 2026-07-28 over `server/discover` — + * and the entry pins that connection's instance to the era the client opened + * with. */ import { fileURLToPath } from 'node:url'; @@ -38,8 +40,9 @@ verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion }); if (protocolVersion === '2025-11-25') { - // Legacy leg: a plain 2025 client is served via initialize, exactly as - // against an undeclared server. + // Legacy leg: a plain 2025 client opens with initialize and the entry + // pins the connection to a 2025-era instance, served exactly as a + // hand-wired stdio server serves it today. const client = new Client({ name: 'plain-2025-client', version: '0' }); try { await client.connect(transport); @@ -55,8 +58,9 @@ verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion } // Modern leg: the auto-negotiating client reaches 2026-07-28 via - // server/discover on the pipe (no initialize is ever written) and - // tools/call round-trips with the per-request envelope. + // server/discover on the pipe (no initialize is ever written), the entry + // pins the connection to a 2026-era instance, and tools/call round-trips + // with the per-request envelope. const sentMethods: string[] = []; const originalSend = transport.send.bind(transport); transport.send = async message => { diff --git a/test/integration/test/__fixtures__/dualEraStdioServer.ts b/test/integration/test/__fixtures__/dualEraStdioServer.ts index 46499f6156..0624dacc7c 100644 --- a/test/integration/test/__fixtures__/dualEraStdioServer.ts +++ b/test/integration/test/__fixtures__/dualEraStdioServer.ts @@ -1,26 +1,30 @@ /** - * A dual-era stdio server fixture: `eraSupport: 'dual-era'` on an otherwise - * ordinary hand-constructed McpServer connected to the unchanged - * StdioServerTransport. Spawned as a real child process by - * `test/server/dualEraStdio.test.ts`. + * A dual-era stdio server fixture: the connection-pinned `serveStdio` entry + * over an ordinary `McpServer` factory. Spawned as a real child process by + * `test/server/dualEraStdio.test.ts`; each spawned process serves exactly one + * connection, pinned to the era its client opens with. */ import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; -const server = new McpServer( - { name: 'dual-era-stdio-fixture', version: '1.0.0' }, - { capabilities: { tools: {} }, instructions: 'dual-era stdio fixture', eraSupport: 'dual-era' } -); - -server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ - content: [{ type: 'text', text }] -})); - -await server.connect(new StdioServerTransport()); +const handle = serveStdio(() => { + const server = new McpServer( + { name: 'dual-era-stdio-fixture', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'dual-era stdio fixture' } + ); + server.registerTool( + 'echo', + { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ + content: [{ type: 'text', text }] + }) + ); + return server; +}); const exit = async () => { - await server.close(); + await handle.close(); // eslint-disable-next-line unicorn/no-process-exit process.exit(0); }; diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 5b833de3eb..8de980f16a 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -6,6 +6,7 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, + setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { McpServer, Server } from '@modelcontextprotocol/server'; @@ -187,15 +188,12 @@ test('should run a fresh initialize handshake after close() when the previous co const supportedProtocolVersions = [MODERN_REVISION, ...SUPPORTED_PROTOCOL_VERSIONS]; const connectModern = async (client: Client) => { - // Serving a 2026-era revision on a hand-constructed instance is a declared act - // (eraSupport); a dual-era instance answers the client's server/discover probe - // per message with no instance binding. - const server = new Server( - { name: 'modern server', version: '1.0' }, - { capabilities: {}, supportedProtocolVersions, eraSupport: 'dual-era' } - ); + const server = new Server({ name: 'modern server', version: '1.0' }, { capabilities: {}, supportedProtocolVersions }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); + // Stand-in for the modern-era server entry (instance binding): mark the server instance + // as serving the modern era so it can answer the client's server/discover probe. + setNegotiatedProtocolVersion(server, MODERN_REVISION); await client.connect(clientTransport); }; diff --git a/test/integration/test/client/discoverRoundtrip.test.ts b/test/integration/test/client/discoverRoundtrip.test.ts index 14b88a7cf2..cdf551d60e 100644 --- a/test/integration/test/client/discoverRoundtrip.test.ts +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -4,17 +4,17 @@ * era-aware counter-offer end to end (a legacy client against a server whose * supported list carries a 2026 revision never sees a 2026 version string). * - * Serving a 2026-era revision on a hand-constructed instance is a declared - * act: the servers under test pass `eraSupport: 'dual-era'` (a modern - * revision in the supported list without that declaration is a - * construction-time TypeError), and a dual-era instance answers the - * `server/discover` probe per message with no instance binding. + * Era is instance state on the server: an inbound `server/discover` is served + * only by a modern-era instance (the method is physically absent from the + * legacy registry). Production binding of modern-era instances belongs to the + * server-side entry that classifies inbound traffic; until it lands these + * tests bind the instance through the package-internal hook it will use. */ import type { Server as HttpServer } from 'node:http'; import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { SdkError, SdkErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import { SdkError, SdkErrorCode, setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; @@ -39,14 +39,14 @@ describe('server/discover round-trip against a modern server', () => { while (cleanups.length > 0) await cleanups.pop()!(); }); - async function startServer(options: { kind: 'dual-era' | 'legacy-only' }) { + async function startServer(options: { modernEraInstance: boolean }) { const httpServer: HttpServer = createServer(); const mcpServer = new McpServer( { name: 'dual-era-server', version: '2.0.0' }, { capabilities: { tools: { listChanged: true } }, - instructions: 'dual era', - ...(options.kind === 'dual-era' ? { supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' as const } : {}) + supportedProtocolVersions: DUAL_ERA_VERSIONS, + instructions: 'dual era' } ); mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ @@ -54,6 +54,11 @@ describe('server/discover round-trip against a modern server', () => { })); const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await mcpServer.connect(serverTransport); + if (options.modernEraInstance) { + // Stand-in for the server-side entry (instance binding): mark the + // instance as serving the modern era so it can answer the probe. + setNegotiatedProtocolVersion(mcpServer.server, MODERN); + } httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); const baseUrl = await listenOnRandomPort(httpServer); cleanups.push(async () => { @@ -65,7 +70,7 @@ describe('server/discover round-trip against a modern server', () => { } it('pin-mode 2026 client: server/discover → version selection, no initialize ever sent', async () => { - const baseUrl = await startServer({ kind: 'dual-era' }); + const baseUrl = await startServer({ modernEraInstance: true }); const { bodies, fetchFn } = recordingFetch(); const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); @@ -83,7 +88,7 @@ describe('server/discover round-trip against a modern server', () => { }); it('auto-mode client selects the modern era on the same server', async () => { - const baseUrl = await startServer({ kind: 'dual-era' }); + const baseUrl = await startServer({ modernEraInstance: true }); const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(baseUrl)); cleanups.push(() => client.close()); @@ -91,11 +96,12 @@ describe('server/discover round-trip against a modern server', () => { expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); }); - it('auto-mode against a server that has not opted into modern-era support falls back to the legacy handshake', async () => { - // A hand-constructed server with the default eraSupport never serves - // server/discover: the probe is answered -32601 and the client falls - // back cleanly on the same connection. - const baseUrl = await startServer({ kind: 'legacy-only' }); + it('auto-mode against the same server NOT bound to the modern era falls back to the legacy handshake', async () => { + // A server instance serves the legacy era until it is bound to the + // modern one (binding is owned by the server-side entry); the probe is + // answered -32601 and the client falls back cleanly on the same + // connection. + const baseUrl = await startServer({ modernEraInstance: false }); const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(baseUrl)); cleanups.push(() => client.close()); @@ -105,8 +111,8 @@ describe('server/discover round-trip against a modern server', () => { expect(result.content).toEqual([{ type: 'text', text: 'fallback' }]); }); - it('a plain legacy client against a dual-era server never meets a 2026 version string (counter-offer ordering, e2e)', async () => { - const baseUrl = await startServer({ kind: 'dual-era' }); + it('a plain legacy client against a server with a dual-era list never meets a 2026 version string (counter-offer ordering, e2e)', async () => { + const baseUrl = await startServer({ modernEraInstance: false }); const { fetchFn } = recordingFetch(); const responses: string[] = []; diff --git a/test/integration/test/server/dualEraStdio.test.ts b/test/integration/test/server/dualEraStdio.test.ts index bc0b63088f..10a32d20f2 100644 --- a/test/integration/test/server/dualEraStdio.test.ts +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -1,16 +1,18 @@ /** - * Real-pipe dual-era stdio coverage: the fixture server - * (`__fixtures__/dualEraStdioServer.ts`, `eraSupport: 'dual-era'`, unchanged - * `StdioServerTransport`) is spawned as a real child process and driven over - * its stdio pipe by + * Real-pipe dual-era stdio coverage for the connection-pinned `serveStdio` + * entry: the fixture server (`__fixtures__/dualEraStdioServer.ts`, one + * `McpServer` factory behind `serveStdio`) is spawned as a real child process + * — once per connection — and driven over its stdio pipe by * - * - a plain 2025 client (the `initialize` vertical, served exactly as today), + * - a plain 2025 client (the `initialize` vertical, served exactly as today, + * with the era gate staying vocabulary-clean on that connection), * - the negotiating client in auto mode (the 2026-07-28 vertical: * `server/discover` on the pipe, then list → call with the per-request - * envelope), and - * - the long-lived era-gate negative on one connection: a legacy-classified - * `server/discover` answers a plain −32601 with zero 2026 vocabulary, while - * the same connection keeps serving both eras. + * envelope; a late claim-less `initialize` on the pinned connection answers + * the version error naming the supported revisions), and + * - a raw probe-then-fallback exchange (`server/discover` answered, then the + * client falls back to `initialize` on the same pipe and is served a normal + * 2025 session by a fresh legacy instance). * * Stdio behavior has no conformance harness (upstream conformance issue #258); * this SDK e2e suite is its referee. @@ -33,6 +35,12 @@ const MODERN = '2026-07-28'; const FORBIDDEN_2026_VOCABULARY = ['2026', 'discover', 'envelope', 'modern', 'era', '_meta', 'io.modelcontextprotocol', 'resultType']; +const modernEnvelope = (clientName: string) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: clientName, version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}); + function spawnFixtureTransport(): StdioClientTransport { return new StdioClientTransport({ command: process.execPath, @@ -78,10 +86,10 @@ async function rawRequest(transport: StdioClientTransport, inbound: JSONRPCMessa ); } -describe('dual-era stdio server over a real child-process pipe', () => { +describe('serveStdio over a real child-process pipe (one connection per spawned process)', () => { vi.setConfig({ testTimeout: 30_000 }); - it('legacy vertical: a plain 2025 client is served via initialize, and the era gate stays vocabulary-clean on the same connection', async () => { + it('legacy-opening connection: a plain 2025 client is served via initialize, and the connection stays vocabulary-clean', async () => { const transport = spawnFixtureTransport(); const client = new Client({ name: 'legacy-pipe-client', version: '1.0.0' }); // Raw writes below produce responses the protocol layer does not track. @@ -99,7 +107,7 @@ describe('dual-era stdio server over a real child-process pipe', () => { expect(result.content).toEqual([{ type: 'text', text: 'over the real pipe' }]); expect(JSON.stringify(inbound)).not.toContain('resultType'); - // Era-gate negative on the SAME connection: a legacy-classified + // Era-gate negative on this 2025-pinned connection: a claim-less // server/discover answers a plain −32601 with zero 2026 vocabulary. const gate = await rawRequest(transport, inbound, { jsonrpc: '2.0', @@ -120,7 +128,7 @@ describe('dual-era stdio server over a real child-process pipe', () => { } }); - it('modern vertical: the auto-negotiating client reaches 2026-07-28 via server/discover on the pipe and both eras serve on one connection', async () => { + it('modern-opening connection: the auto-negotiating client reaches 2026-07-28 via server/discover, the connection pins modern, and a late initialize is rejected with the supported list', async () => { const transport = spawnFixtureTransport(); const outbound = recordOutbound(transport); const client = new Client({ name: 'modern-pipe-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); @@ -138,16 +146,7 @@ describe('dual-era stdio server over a real child-process pipe', () => { // Modern vertical: list → call, every request carrying the per-request envelope. // (Attaching it explicitly is the documented stop-gap until automatic // per-request envelope emission lands client-side.) - const envelope = { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'modern-pipe-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; - // The list leg is asserted at the wire level: the 2026 wire schema - // for cacheable list results requires the ttlMs/cacheScope stamps, - // whose server-side stamping ships with the result-stamping - // milestone — the client-side typed decode of tools/list on the - // modern era completes once that lands. + const envelope = modernEnvelope('modern-pipe-client'); const modernList = await rawRequest(transport, inbound, { jsonrpc: '2.0', id: 'raw-modern-list', @@ -164,31 +163,68 @@ describe('dual-era stdio server over a real child-process pipe', () => { }); expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); - // Both eras concurrently on ONE connection: a raw legacy (envelope-less) - // request on the same pipe is served on the 2025 era… - const legacyList = await rawRequest(transport, inbound, { + // The connection is pinned to the 2026 era: a late claim-less + // initialize is answered with the version error naming the + // supported revisions, never served as a legacy handshake. + const lateInitialize = await rawRequest(transport, inbound, { jsonrpc: '2.0', - id: 'raw-legacy-list', - method: 'tools/list', - params: {} + id: 'raw-late-initialize', + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'late', version: '0' } } }); - const legacyResult = (legacyList as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; - expect(legacyResult?.tools?.map(tool => tool.name)).toEqual(['echo']); - expect(legacyResult?.resultType).toBeUndefined(); + const lateError = (lateInitialize as { error: { code: number; data?: { supported?: string[] } } }).error; + expect(lateError.code).toBe(-32_004); + expect(lateError.data?.supported).toContain(MODERN); + } finally { + await client.close(); + } + }); - // …while the era-gate negative holds on the same connection too. - const gate = await rawRequest(transport, inbound, { + it('probe-then-fallback connection: server/discover is answered, then an initialize on the same pipe is served a normal 2025 session', async () => { + const transport = spawnFixtureTransport(); + const inbound: JSONRPCMessage[] = []; + transport.onmessage = message => void inbound.push(message); + transport.onerror = () => {}; + + try { + await transport.start(); + + // The probe is answered by the optimistically built modern instance. + const discover = await rawRequest(transport, inbound, { jsonrpc: '2.0', - id: 'raw-gate-2', - method: 'subscriptions/listen', - params: {} + id: 'probe-1', + method: 'server/discover', + params: { _meta: modernEnvelope('fallback-pipe-client') } }); - const error = (gate as { error: { code: number; message: string; data?: unknown } }).error; - expect(error.code).toBe(-32_601); - expect(error.message).toBe('Method not found'); - expect(error.data).toBeUndefined(); + const discoverResult = (discover as { result?: { supportedVersions?: string[]; resultType?: string } }).result; + expect(discoverResult?.supportedVersions).toEqual([MODERN]); + expect(discoverResult?.resultType).toBe('complete'); + + // The client shares no modern revision and falls back to the 2025 + // handshake on the same connection: a fresh legacy instance serves it. + const init = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'fallback-init', + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'fallback-pipe-client', version: '1.0.0' } + } + }); + const initResult = (init as { result?: { protocolVersion?: string } }).result; + expect(initResult?.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(JSON.stringify(init)).not.toContain('resultType'); + + await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + // The legacy session works end to end after the fallback. + const list = await rawRequest(transport, inbound, { jsonrpc: '2.0', id: 'fallback-list', method: 'tools/list', params: {} }); + const listResult = (list as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; + expect(listResult?.tools?.map(tool => tool.name)).toEqual(['echo']); + expect(listResult?.resultType).toBeUndefined(); } finally { - await client.close(); + await transport.close(); } }); }); From 3f2ca341ef18095d66f22834b5d15186cfa03467 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:44:49 +0100 Subject: [PATCH 22/37] feat(server): default createMcpHandler to stateless legacy serving; export isLegacyRequest; remove the handler-valued legacy option (#2316) --- .../create-mcp-handler-legacy-revision.md | 9 + .changeset/create-mcp-handler.md | 10 +- docs/migration-SKILL.md | 10 + docs/migration.md | 57 +++- examples/server/src/dualEraStreamableHttp.ts | 37 +- packages/server/src/index.ts | 2 +- .../server/src/server/createMcpHandler.ts | 322 +++++++++++++----- .../test/server/createMcpHandler.test.ts | 243 +++++++++---- .../createMcpHandlerStatelessLiteral.test.ts | 4 +- .../server/legacyStatelessFallback.test.ts | 2 +- test/e2e/CLAUDE.md | 11 +- test/e2e/helpers/index.ts | 19 +- test/e2e/requirements.ts | 10 +- .../scenarios/hosting-entry-session.test.ts | 142 +++++--- test/e2e/scenarios/hosting-entry.test.ts | 23 +- test/e2e/types.ts | 13 +- .../test/server/createMcpHandler.test.ts | 29 +- 17 files changed, 633 insertions(+), 310 deletions(-) create mode 100644 .changeset/create-mcp-handler-legacy-revision.md diff --git a/.changeset/create-mcp-handler-legacy-revision.md b/.changeset/create-mcp-handler-legacy-revision.md new file mode 100644 index 0000000000..4212856377 --- /dev/null +++ b/.changeset/create-mcp-handler-legacy-revision.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Revise `createMcpHandler`'s legacy handling (a behavior change to the unreleased entry). The entry now serves 2025-era (non-envelope) traffic **by default** through per-request stateless serving from the same factory — `legacy: 'stateless'` is the default rather than an +opt-in — and the strict, modern-only posture is selected with the new `legacy: 'reject'` value (the earlier alpha's default). The handler-valued `legacy` option (bring-your-own legacy serving) is removed: existing legacy deployments (for example a sessionful streamable +HTTP wiring) keep serving 2025 traffic by routing in user land with the new `isLegacyRequest(request, parsedBody?)` export, which runs the entry's own classification step — it returns `true` only for requests with no per-request `_meta` envelope claim, while malformed or +incomplete modern claims are NOT legacy and must be routed to the modern handler, which answers them with the documented validation errors. The predicate classifies a clone, so the routed request body stays readable. `legacyStatelessFallback` remains exported as a +standalone fetch-shaped handler with the same stateless serving as the default. diff --git a/.changeset/create-mcp-handler.md b/.changeset/create-mcp-handler.md index d103fb0ac1..35eeccada2 100644 --- a/.changeset/create-mcp-handler.md +++ b/.changeset/create-mcp-handler.md @@ -3,8 +3,8 @@ --- Add `createMcpHandler(factory, { legacy?, onerror?, responseMode? })`, an HTTP entry point that serves the 2026-07-28 draft revision per request: each envelope-carrying request is classified once, served on a fresh instance from the factory bound to the claimed revision, -and answered with a JSON body or a lazily-upgraded SSE stream. 2025-era serving is opt-in through the `legacy` slot (`'stateless'` for per-request stateless serving via the existing streamable HTTP transport, or any fetch-shaped handler for bring-your-own wiring); without -the slot the endpoint is modern-only and rejects 2025-era requests with the unsupported-protocol-version error naming its supported revisions. The handler exposes a web-standard `fetch(request, { authInfo?, parsedBody? })` face and a duck-typed `node(req, res, parsedBody?)` -face, plus `close()` for tearing down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the canonical slot value), the `PerRequestHTTPServerTransport` single-exchange transport and the `classifyInboundRequest` classifier for hand-wired compositions, and -the supporting types. `responseMode: 'json'` never streams and drops mid-call notifications (progress, logging and other related messages emitted before the result); listen-class subscription streams are always served over SSE. The entry performs no Origin/Host validation -(use the middleware packages) and no token verification — `authInfo` is pass-through and never derived from request headers. +and answered with a JSON body or a lazily-upgraded SSE stream. 2025-era serving is selected with the `legacy` option (`'stateless'` — the default — for per-request stateless serving via the existing streamable HTTP transport, `'reject'` for a modern-only strict endpoint +that answers 2025-era requests with the unsupported-protocol-version error naming its supported revisions). The handler exposes a web-standard `fetch(request, { authInfo?, parsedBody? })` face and a duck-typed `node(req, res, parsedBody?)` +face, plus `close()` for tearing down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the same stateless legacy serving as a standalone fetch-shaped handler), the `PerRequestHTTPServerTransport` single-exchange transport and the +`classifyInboundRequest` classifier for hand-wired compositions, and the supporting types. `responseMode: 'json'` never streams and drops mid-call notifications (progress, logging and other related messages emitted before the result); listen-class subscription streams are +always served over SSE. The entry performs no Origin/Host validation (use the middleware packages) and no token verification — `authInfo` is pass-through and never derived from request headers. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index f8014412fb..b44c5a1f80 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -550,6 +550,16 @@ These can require code changes: - `Server.getClientCapabilities()`, `getClientVersion()` and `getNegotiatedProtocolVersion()` are deprecated but functional: prefer the per-request context (`ctx.mcpReq.envelope`) on 2026-07-28 requests. No mechanical change required yet; plan the move before the deprecations are removed. - `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default (requests without an `Origin` header are unaffected). Browser-served clients on a non-localhost origin need `allowedOrigins: [...]`, which replaces the default localhost allowlist — Origin validation cannot be disabled for localhost-class binds. +### Server (HTTP entry: createMcpHandler — serving the 2026-07-28 draft revision) + +New in 2.0 — v1 has no equivalent API. How v1 Streamable HTTP hosting maps onto the entry: + +- `createMcpHandler(factory)` from `@modelcontextprotocol/server` serves the 2026-07-28 draft revision per request and, out of the box, also serves 2025-era (non-envelope) traffic through per-request stateless serving (`legacy: 'stateless'`, the default) — one factory, one endpoint, both eras. A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, fresh transport per request) maps directly onto the default entry. +- Pass `legacy: 'reject'` for a strict, modern-only endpoint: 2025-era requests are rejected with the unsupported-protocol-version error naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. The option type is `legacy?: 'stateless' | 'reject'`. +- An existing sessionful v1 Streamable HTTP setup (a `StreamableHTTPServerTransport` wiring with session IDs) keeps serving 2025 clients by routing in user land in front of a strict entry: `if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); return strictHandler.fetch(request)` where `strictHandler = createMcpHandler(factory, { legacy: 'reject' })`. +- `isLegacyRequest(request: Request, parsedBody?: unknown): Promise` from `@modelcontextprotocol/server` is the entry's own classification step. Returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler, never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed. +- `legacyStatelessFallback(factory)` is exported as a standalone fetch-shaped handler producing the same stateless legacy serving as the default. + ### Server (stdio / long-lived connections) - A hand-constructed `Server`/`McpServer` connected to a `StdioServerTransport` serves only the 2025-era protocol it was written for: today's behavior, byte-identical — no change required during a mechanical migration. diff --git a/docs/migration.md b/docs/migration.md index 1e9cbfdbde..cd51f7dbbc 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1032,19 +1032,16 @@ already does; automatic envelope emission for every request is a client-side fol ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` -The server package now ships an HTTP entry point that serves the 2026-07-28 draft revision per request, with 2025-era serving available as an **opt-in** slot: +The server package now ships an HTTP entry point that serves the 2026-07-28 draft revision per request and, **by default, also serves 2025-era traffic** per request through the established stateless idiom — one factory, one endpoint, both eras: ```typescript import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -const handler = createMcpHandler( - ctx => { - const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); - // register tools/resources/prompts once — the same factory backs both eras - return server; - }, - { legacy: 'stateless' } -); +const handler = createMcpHandler(ctx => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once — the same factory backs both eras + return server; +}); // Web-standard runtimes (Cloudflare Workers, Deno, Bun, Hono): // handler.fetch(request) @@ -1052,21 +1049,45 @@ const handler = createMcpHandler( // handler.node(req, res, req.body) ``` -How the `legacy` slot behaves: - -- **omitted** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no silent 2025 - serving without the slot.** -- **`legacy: 'stateless'`** — 2025-era traffic is additionally served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only `sessionIdGenerator: undefined`. The exported - `legacyStatelessFallback(factory)` is the same handler as a standalone value. -- **`legacy: `** — bring your own legacy serving (for example an existing sessionful `WebStandardStreamableHTTPServerTransport` wiring). Requests are handed to it untouched and its lifecycle stays yours. +How the `legacy` option behaves: + +- **omitted / `legacy: 'stateless'`** (the default) — 2025-era (non-envelope) traffic is served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only + `sessionIdGenerator: undefined`. Because this serving is per-request and stateless, GET and DELETE (2025 session operations) are answered `405` / `Method not allowed.`, exactly like the canonical stateless example. The exported `legacyStatelessFallback(factory)` is the + same serving as a standalone fetch-shaped handler for hand-wired compositions. +- **`legacy: 'reject'`** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no + 2025 serving in this mode.** + +> **If you have an existing sessionful 1.x Streamable HTTP setup** (a `StreamableHTTPServerTransport` wiring with session IDs that your deployed 2025-era clients depend on), keep that handler serving 2025 traffic and route it in front of a strict (`legacy: 'reject'`) +> entry with the exported `isLegacyRequest(request)` predicate. The predicate is the entry's own classification step (the same code `createMcpHandler` runs to decide a request is not on the modern path), so a composition that branches on it can never disagree with the +> entry: +> +> ```typescript +> // An existing sessionful 1.x streamable HTTP wiring keeps serving 2025 clients, routed in front of a strict entry. +> import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server'; +> +> const modern = createMcpHandler(factory, { legacy: 'reject' }); +> +> export default { +> async fetch(request: Request): Promise { +> if (await isLegacyRequest(request)) { +> return myExistingLegacyHandler(request); // e.g. an existing sessionful WebStandardStreamableHTTPServerTransport wiring +> } +> return modern.fetch(request); +> } +> }; +> ``` +> +> `isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, and non-JSON bodies). It returns `false` for everything the +> modern path answers — including a request carrying a **malformed** modern claim, which the modern path rejects with `-32602` — so route `false` traffic to the modern handler, never to your legacy handler. The predicate classifies a clone, so the request body stays +> readable for whichever handler you route to (pass an already-parsed body as the second argument if the stream has been consumed). The optional `responseMode` controls how modern request exchanges are answered: `'auto'` (default) returns a single JSON body and lazily upgrades to an SSE stream when the handler emits a related message before its result; `'sse'` always streams; **`'json'` never streams and DROPS mid-call notifications** (progress, logging, and any other related message emitted before the result) — only the terminal result is delivered. Subscription (listen-class) streams are always served over SSE regardless of the setting. `onerror` receives out-of-band errors and rejected requests for logging. The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` / attached as `req.auth` on the Node face is forwarded to handlers as-is and never derived from -request headers. Power users who want to compose routing themselves can use the exported `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around -(`const { fetch } = handler`). +request headers. Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed +around (`const { fetch } = handler`). ### Serving the 2026-07-28 draft revision on stdio: `serveStdio` diff --git a/examples/server/src/dualEraStreamableHttp.ts b/examples/server/src/dualEraStreamableHttp.ts index b10121ebad..5891bb5bc4 100644 --- a/examples/server/src/dualEraStreamableHttp.ts +++ b/examples/server/src/dualEraStreamableHttp.ts @@ -2,22 +2,25 @@ * Dual-era HTTP serving with `createMcpHandler`: one factory, one endpoint, * both protocol eras. * - * The same factory backs every serving mode; the `MCP_LEGACY_MODE` environment - * variable selects how 2025-era (non-envelope) traffic is handled: + * The same factory backs both legacy postures; the `MCP_LEGACY_MODE` + * environment variable selects how 2025-era (non-envelope) traffic is handled: * - * - `MCP_LEGACY_MODE=none` → modern-only strict: 2026-07-28 requests are + * - unset / `MCP_LEGACY_MODE=stateless` → (the entry's default) 2025-era + * traffic is served per-request via the + * stateless idiom from the same factory. + * - `MCP_LEGACY_MODE=reject` → modern-only strict: 2026-07-28 requests are * served, 2025-era requests get the documented * rejection naming the supported revisions. - * - `MCP_LEGACY_MODE=stateless` → (default) 2025-era traffic is additionally - * served per-request via the stateless idiom. - * - `MCP_LEGACY_MODE=byo` → the same, but wired explicitly through the - * exported `legacyStatelessFallback` slot value - * (stand-in for bringing your own legacy handler, - * e.g. an existing sessionful wiring). + * + * To keep an existing sessionful 2025 deployment serving legacy traffic next + * to a strict endpoint, route in user land with the exported `isLegacyRequest` + * predicate in front of a `legacy: 'reject'` handler (see the createMcpHandler + * section of docs/migration.md for the pattern) — there is no handler-valued + * `legacy` option. * * Run with `tsx examples/server/src/dualEraStreamableHttp.ts`, then point any * plain 2025 client at http://localhost:3000/mcp (served through the legacy - * slot when one is configured). A `versionNegotiation: { mode: 'auto' }` + * fallback unless `reject` is selected). A `versionNegotiation: { mode: 'auto' }` * client negotiates 2026-07-28 against the same endpoint, but automatic * envelope emission for every request is still a client-side follow-up: * ordinary typed calls (for example `callTool`) must attach the per-request @@ -27,11 +30,11 @@ */ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import type { CallToolResult, CreateMcpHandlerOptions, McpRequestContext } from '@modelcontextprotocol/server'; -import { createMcpHandler, legacyStatelessFallback, McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import type { Request, Response } from 'express'; import * as z from 'zod/v4'; -// One factory for both legs (and every slot state): tools are defined once and +// One factory for both legs (and both postures): tools are defined once and // served identically to 2025-era and 2026-era clients. const getServer = (ctx: McpRequestContext) => { const server = new McpServer( @@ -60,13 +63,9 @@ const legacyMode = process.env.MCP_LEGACY_MODE ?? 'stateless'; const options: CreateMcpHandlerOptions = { onerror: error => console.error('MCP handler error:', error.message) }; -if (legacyMode === 'stateless') { - options.legacy = 'stateless'; -} else if (legacyMode === 'byo') { - // Bring-your-own legacy serving: any fetch-shaped handler works here. The - // canonical stateless fallback doubles as the simplest BYO value; an - // existing sessionful streamable HTTP wiring would be passed the same way. - options.legacy = legacyStatelessFallback(getServer); +if (legacyMode === 'reject') { + // Modern-only strict: turn the default stateless legacy fallback off. + options.legacy = 'reject'; } const handler = createMcpHandler(getServer, options); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 408f4be340..4420e20712 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -18,7 +18,7 @@ export type { NodeIncomingMessageLike, NodeServerResponseLike } from './server/createMcpHandler.js'; -export { createMcpHandler, legacyStatelessFallback } from './server/createMcpHandler.js'; +export { createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './server/createMcpHandler.js'; export type { AnyToolHandler, BaseToolCallback, diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 3b4f45ab3d..e5f79dec85 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -1,6 +1,6 @@ /** * `createMcpHandler` — the HTTP entry point for serving the 2026-07-28 protocol - * revision, with 2025-era serving available as an opt-in slot. + * revision, with old-school stateless 2025-era serving as the default fallback. * * The entry classifies every inbound HTTP request exactly once (body-primary, * via {@linkcode classifyInboundRequest}) and routes it: @@ -11,9 +11,16 @@ * transport. * - Requests without an envelope claim (including `initialize`, GET/DELETE * session operations, and 2025-era notification POSTs) are legacy traffic. - * When the `legacy` slot is configured they are handed to it untouched; when - * it is not, the endpoint is modern-only strict and answers the documented - * rejection cells. There is no silent 2025 serving without the slot. + * By default they are served per request through the stateless idiom from + * the same factory (`legacy: 'stateless'`); with `legacy: 'reject'` the + * endpoint is modern-only strict and answers the documented rejection cells + * instead — there is no 2025 serving in that mode. + * + * There is no handler-valued `legacy` option: an existing legacy deployment + * (for example a sessionful streamable HTTP wiring) keeps serving 2025 traffic + * by routing in user land with {@linkcode isLegacyRequest} — the entry's own + * classification step, exported as a predicate — in front of a strict + * (`legacy: 'reject'`) handler. * * The entry performs no Origin/Host validation (mount the origin/host * validation middleware in front of it) and no token verification — `authInfo` @@ -23,6 +30,7 @@ import type { AuthInfo, ClientCapabilities, Implementation, + InboundClassificationOutcome, InboundLadderRejection, InboundLegacyRoute, InboundModernRoute, @@ -77,9 +85,10 @@ export interface McpRequestContext { * The protocol era the constructed instance will serve: `modern` for * 2026-07-28 (per-request envelope) traffic, `legacy` for 2025-era * traffic. Under {@linkcode createMcpHandler} a `legacy` instance serves - * one request through the `legacy: 'stateless'` slot; under `serveStdio` - * it serves a connection that opened with the 2025 handshake and stays - * pinned to that era for its lifetime. + * one request through the stateless legacy fallback (the default — + * `legacy: 'reject'` endpoints are strict and never construct one); under + * `serveStdio` it serves a connection that opened with the 2025 handshake + * and stays pinned to that era for its lifetime. */ era: 'legacy' | 'modern'; /** @@ -101,7 +110,7 @@ export interface McpRequestContext { */ export type McpServerFactory = (ctx: McpRequestContext) => McpServer | Server | Promise; -/** Caller-provided per-request inputs for {@linkcode McpHttpHandler.fetch} and legacy slot handlers. */ +/** Caller-provided per-request inputs for {@linkcode McpHttpHandler.fetch} and fetch-shaped legacy handlers ({@linkcode LegacyHttpHandler}). */ export interface McpHandlerRequestOptions { /** * Validated authentication information for the request. Strictly @@ -114,9 +123,11 @@ export interface McpHandlerRequestOptions { } /** - * A fetch-shaped handler serving 2025-era traffic for the `legacy` slot: - * receives the original request untouched (plus the caller-provided - * pass-through options) and produces the HTTP response. + * A fetch-shaped handler serving 2025-era traffic: the shape produced by + * {@linkcode legacyStatelessFallback}, and the shape a hand-wired composition + * routes legacy requests to (see {@linkcode isLegacyRequest}). It is not a + * `legacy` option value — the entry's own legacy serving is selected by the + * `'stateless' | 'reject'` posture only. */ export type LegacyHttpHandler = (request: Request, options?: McpHandlerRequestOptions) => Promise; @@ -125,19 +136,26 @@ export interface CreateMcpHandlerOptions { /** * How 2025-era (non-envelope) traffic is served: * - * - omitted — modern-only strict: legacy-classified requests are rejected - * with the unsupported-protocol-version error naming the endpoint's - * supported revisions (legacy-classified notifications are acknowledged - * with `202` and dropped). **There is no silent 2025 serving.** - * - `'stateless'` — serve legacy traffic with the per-request stateless - * idiom (a fresh instance from the same factory and a streamable HTTP - * transport constructed with only `sessionIdGenerator: undefined`). - * Equivalent to passing {@linkcode legacyStatelessFallback | legacyStatelessFallback(factory)}. - * - a handler — bring your own legacy serving (for example an existing - * sessionful streamable HTTP wiring); requests are handed to it - * byte-untouched and its lifecycle stays yours. + * - `'stateless'` (the default, also when the option is omitted) — + * old-school stateless serving: each legacy request is answered by a + * fresh instance from the same factory over a streamable HTTP transport + * constructed with only `sessionIdGenerator: undefined` (the established + * stateless idiom). Because serving is per-request and stateless, GET and + * DELETE (2025 session operations) are answered with `405` / + * `Method not allowed.`. + * - `'reject'` — modern-only strict: legacy-classified requests are + * rejected with the unsupported-protocol-version error naming the + * endpoint's supported revisions (legacy-classified notifications are + * acknowledged with `202` and dropped). **There is no 2025 serving in + * this mode.** + * + * There is no handler-valued option: to keep an existing legacy deployment + * (for example a sessionful streamable HTTP wiring) serving 2025 traffic + * next to this entry, route in user land with {@linkcode isLegacyRequest} + * in front of a `legacy: 'reject'` handler — see that predicate's + * documentation for the pattern. */ - legacy?: 'stateless' | LegacyHttpHandler; + legacy?: 'stateless' | 'reject'; /** Callback for out-of-band errors and rejected requests (reporting only; it never alters the response). */ onerror?: (error: Error) => void; /** @@ -194,8 +212,8 @@ export interface McpHttpHandler { /** * Tears down the modern leg: aborts in-flight modern exchanges and closes * their per-request instances. Legacy serving is unaffected — the - * `'stateless'` slot is per-request by construction, and a bring-your-own - * legacy handler's lifecycle stays with its owner. + * stateless fallback is per-request by construction and holds nothing + * between exchanges. */ close: () => Promise; } @@ -257,25 +275,27 @@ function internalServerErrorResponse(id: RequestId | null = null): Response { } /* ------------------------------------------------------------------------ * - * The canonical legacy slot value + * The default legacy fallback * ------------------------------------------------------------------------ */ /** - * The canonical `legacy` slot value: per-request stateless serving of 2025-era - * traffic using the same factory as the modern path. + * The entry's default legacy serving (`legacy: 'stateless'`): per-request + * stateless serving of 2025-era traffic using the same factory as the modern + * path. Exported as a standalone building block for hand-wired compositions + * (for example mounting legacy stateless serving on its own route next to a + * strict modern endpoint). * * Each POST is served by a fresh instance from the factory connected to a * fresh streamable HTTP transport constructed with only * `sessionIdGenerator: undefined` — the established stateless idiom, unchanged. * Because serving is per-request and stateless, GET and DELETE (2025 session * operations) are answered with `405` / `Method not allowed.`, exactly like the - * canonical stateless example. `createMcpHandler(factory, { legacy: 'stateless' })` - * is shorthand for passing `legacyStatelessFallback(factory)` here explicitly. + * canonical stateless example. * * The optional `onerror` callback receives factory and serving failures on * this leg (reporting only — the response stays the 500 internal-error body). - * The entry passes its own `onerror` here when expanding `legacy: 'stateless'`, - * so legacy-leg failures are never silently swallowed. + * The entry passes its own `onerror` here when expanding the default, so + * legacy-leg failures are never silently swallowed. */ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler { return async (request, options) => { @@ -361,14 +381,146 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er }; } +/* ------------------------------------------------------------------------ * + * The entry's classification step (shared with isLegacyRequest) + * ------------------------------------------------------------------------ */ + +/** The outcome of the entry's classification step for one inbound HTTP request. */ +type EntryClassification = + /** The body bytes could not be read at all (a failing stream, not malformed JSON). */ + | { step: 'unreadable-body' } + /** A POST with an empty or non-JSON body: nothing to classify, so there is no envelope claim. */ + | { step: 'no-json-body'; forwardRequest: Request } + /** A classifiable request, with the classifier's routing outcome. */ + | { step: 'classified'; outcome: InboundClassificationOutcome; body: unknown; parsedBody: unknown; forwardRequest: Request }; + +/** + * The entry's classification step: read the request body exactly once (unless + * a pre-parsed body is supplied) and classify the request with + * {@linkcode classifyInboundRequest}. This is the single code path behind both + * {@linkcode createMcpHandler}'s routing and the exported + * {@linkcode isLegacyRequest} predicate, so the two can never disagree. + */ +async function classifyEntryRequest(request: Request, providedParsedBody?: unknown): Promise { + const httpMethod = request.method.toUpperCase(); + + let body: unknown; + let parsedBody = providedParsedBody; + let forwardRequest = request; + let unparseable = false; + + if (httpMethod === 'POST') { + if (parsedBody === undefined) { + // Read the body exactly once for classification, keeping an unread + // copy of the original bytes for the legacy leg (web-standard + // request bodies are single-use). + forwardRequest = request.clone(); + let bodyText: string; + try { + bodyText = await request.text(); + } catch { + return { step: 'unreadable-body' }; + } + try { + body = bodyText.length === 0 ? undefined : JSON.parse(bodyText); + } catch { + unparseable = true; + } + if (!unparseable && body !== undefined) { + parsedBody = body; + } + } else { + body = parsedBody; + } + + if (unparseable || body === undefined) { + return { step: 'no-json-body', forwardRequest }; + } + } + + const outcome = classifyInboundRequest({ + httpMethod, + protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + ...(body !== undefined && { body }) + }); + return { step: 'classified', outcome, body, parsedBody, forwardRequest }; +} + +/** + * Whether {@linkcode createMcpHandler} would route this request to its legacy + * (2025-era) serving rather than the modern (2026-07-28) path. + * + * This is the entry's own classification step exported as a predicate — it + * runs exactly the code `createMcpHandler` runs to make the routing decision, + * not a re-implementation — so a hand-wired composition that branches on it + * can never disagree with the entry. Use it to keep an existing legacy + * deployment (for example a sessionful streamable HTTP wiring) serving 2025 + * traffic next to a strict modern endpoint, now that the entry has no + * handler-valued `legacy` option: + * + * ```ts + * import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server'; + * + * const modern = createMcpHandler(factory, { legacy: 'reject' }); + * + * export default { + * async fetch(request: Request): Promise { + * if (await isLegacyRequest(request)) { + * // e.g. an existing sessionful WebStandardStreamableHTTPServerTransport wiring + * return myExistingLegacyHandler(request); + * } + * return modern.fetch(request); + * } + * }; + * ``` + * + * Semantics (identical to the entry's routing): + * + * - Returns `true` only for requests with no per-request `_meta` envelope + * claim: claim-less POSTs (including the `initialize` handshake and 2025-era + * notification POSTs without a modern protocol-version header), body-less + * GET/DELETE session operations, all-legacy JSON-RPC batch arrays, posted + * JSON-RPC responses, and POSTs whose body is empty or not valid JSON. + * - Returns `false` for everything the modern path answers, including its + * validation-ladder rejections: a request carrying the envelope claim (even + * one naming a revision the endpoint does not serve — the modern path + * answers it with the unsupported-protocol-version error), a malformed + * envelope behind a present claim (answered `-32602`), a request whose + * `MCP-Protocol-Version` header names a modern revision but that lacks the + * envelope (`-32602`), and header/body mismatches (`-32001`). Consumers + * routing on the predicate must send `false` traffic to the modern handler, + * never to a legacy handler — the modern path owns those error answers. + * - `server/discover` probes sent by negotiating clients always carry the + * envelope claim, so they are never legacy; a hand-built claim-less POST to + * a method named `server/discover` has no claim and classifies legacy, + * exactly as the entry itself routes it. + * + * The body is read from a clone, so the passed request stays readable for + * whichever handler the caller routes it to. If the body has already been + * consumed (for example behind `express.json()`), pass the parsed body as the + * second argument and no body read happens at all — without it the predicate + * cannot classify a consumed POST body (cloning a used body throws a + * `TypeError`), so the call rejects instead of guessing. + */ +export async function isLegacyRequest(request: Request, parsedBody?: unknown): Promise { + // Classify a clone so the caller's request body stays readable; with a + // pre-parsed body (or a body-less method) nothing is read and no clone is + // needed. + const probe = parsedBody === undefined && request.method.toUpperCase() === 'POST' ? request.clone() : request; + const classified = await classifyEntryRequest(probe, parsedBody); + return classified.step === 'no-json-body' || (classified.step === 'classified' && classified.outcome.kind === 'legacy'); +} + /* ------------------------------------------------------------------------ * * The entry * ------------------------------------------------------------------------ */ /** * Creates an HTTP handler that serves the 2026-07-28 protocol revision from a - * per-request server factory, with 2025-era serving available through the - * opt-in `legacy` slot. + * per-request server factory and, by default, falls back to old-school + * stateless serving for 2025-era traffic. Pass `legacy: 'reject'` for a + * modern-only strict endpoint. * * Mounting: `handler.fetch` is the web-standard face (Cloudflare Workers, * Deno, Bun, Hono's `c.req.raw`); `handler.node(req, res, req.body)` is the @@ -390,12 +542,15 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er * ``` * * Use ONE factory for both legs: the same tools/resources/prompts definition - * backs the modern path and the `legacy: 'stateless'` slot, so the two eras - * can never drift apart. Power users who want to compose the routing - * themselves (for example to mount the modern path and an existing legacy - * deployment on different routes) can use the exported building blocks - * directly: {@linkcode classifyInboundRequest} for the era decision and - * `PerRequestHTTPServerTransport` for single-exchange serving. + * backs the modern path and the stateless legacy fallback, so the two eras can + * never drift apart. To keep an existing legacy deployment (for example a + * sessionful streamable HTTP wiring) serving 2025 traffic instead of the + * stateless fallback, route in user land with {@linkcode isLegacyRequest} in + * front of a strict handler — see that predicate's documentation for the + * pattern. Power users composing transport-neutral routing can also use the + * exported building blocks directly: {@linkcode classifyInboundRequest} for + * the era decision and `PerRequestHTTPServerTransport` for single-exchange + * serving. * * The entry performs no token verification: `authInfo` given to the faces is * passed through to handlers and the factory as-is and is never derived from @@ -404,6 +559,16 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHandlerOptions = {}): McpHttpHandler { const { legacy, onerror, responseMode } = options; + // Construction-time guard for JavaScript callers passing a handler as the + // legacy value: the option only selects a posture ('stateless' | 'reject'). + // Failing loudly here beats silently treating the handler as the default. + if (typeof legacy === 'function') { + throw new TypeError( + "The 'legacy' option only accepts 'stateless' or 'reject', not a handler function. To serve 2025-era traffic with your own " + + "handler, route in user land with the exported isLegacyRequest(request) predicate in front of a strict (legacy: 'reject') handler." + ); + } + /** Modern per-request instances with an exchange still in flight (close() tears these down). */ const inflight = new Set(); let closed = false; @@ -417,7 +582,9 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } }; - const legacyHandler: LegacyHttpHandler | undefined = legacy === 'stateless' ? legacyStatelessFallback(factory, reportError) : legacy; + // The default posture is the stateless fallback; 'reject' is the only way + // to turn legacy serving off (modern-only strict). + const legacyHandler: LegacyHttpHandler | undefined = legacy === 'reject' ? undefined : legacyStatelessFallback(factory, reportError); async function serveModern( route: InboundModernRoute, @@ -561,57 +728,24 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } async function handle(request: Request, requestOptions?: McpHandlerRequestOptions): Promise { - const httpMethod = request.method.toUpperCase(); const authInfo = requestOptions?.authInfo; + const classified = await classifyEntryRequest(request, requestOptions?.parsedBody); - let body: unknown; - let parsedBody = requestOptions?.parsedBody; - let forwardRequest = request; - let unparseable = false; - - if (httpMethod === 'POST') { - if (parsedBody === undefined) { - // Read the body exactly once for classification, keeping an - // unread copy of the original bytes for the legacy slot - // (web-standard request bodies are single-use). - forwardRequest = request.clone(); - let bodyText: string; - try { - bodyText = await request.text(); - } catch { - return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body could not be read'); - } - try { - body = bodyText.length === 0 ? undefined : JSON.parse(bodyText); - } catch { - unparseable = true; - } - if (!unparseable && body !== undefined) { - parsedBody = body; - } - } else { - body = parsedBody; - } - - if (unparseable || body === undefined) { - // No JSON body to classify: there is no envelope claim, so this - // is legacy traffic when a slot is configured (the legacy leg - // answers its own parse error, unchanged), and a parse error - // otherwise. - if (legacyHandler !== undefined) { - return legacyHandler(forwardRequest, { ...(authInfo !== undefined && { authInfo }) }); - } - return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body is not valid JSON'); + if (classified.step === 'unreadable-body') { + return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body could not be read'); + } + if (classified.step === 'no-json-body') { + // No JSON body to classify: there is no envelope claim, so this is + // legacy traffic when legacy serving is configured (the legacy leg + // answers its own parse error, unchanged), and a parse error + // otherwise. + if (legacyHandler !== undefined) { + return legacyHandler(classified.forwardRequest, { ...(authInfo !== undefined && { authInfo }) }); } + return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body is not valid JSON'); } - const outcome = classifyInboundRequest({ - httpMethod, - protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, - mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, - ...(body !== undefined && { body }) - }); - + const { outcome, body, parsedBody, forwardRequest } = classified; try { switch (outcome.kind) { case 'reject': { @@ -627,9 +761,9 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } } catch (error) { // Entry-internal failure while serving a classified request (a - // throwing factory, a failed connect, a throwing bring-your-own - // legacy handler): the parsed body is in scope here, so the 500 - // body echoes the request id when it could be read. + // throwing factory or a failed connect, on either leg): the parsed + // body is in scope here, so the 500 body echoes the request id when + // it could be read. reportError(toError(error)); return internalServerErrorResponse(echoableRequestId(body)); } @@ -777,10 +911,10 @@ async function nodeRequestToFetchRequest(req: NodeIncomingMessageLike, parsedBod // The caller already consumed and parsed the Node stream (the // documented `handler.node(req, res, req.body)` mounting behind // `express.json()`), so the bytes cannot be re-read. Re-serialize - // the parsed value so consumers of the forwarded Request — a - // bring-your-own legacy handler reading `request.json()`/`text()` - // in particular — still receive the body, and replace the entity - // headers that described the original raw bytes. + // the parsed value so consumers of the forwarded Request — anything + // on the legacy leg reading `request.json()`/`text()` instead of + // the pass-through parsedBody — still receive the body, and replace + // the entity headers that described the original raw bytes. const serialized: string | undefined = JSON.stringify(parsedBody); headers.delete('content-encoding'); headers.delete('transfer-encoding'); diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index a07df6f264..0a069376ac 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -1,10 +1,11 @@ /** - * createMcpHandler: the slot-model HTTP entry. + * createMcpHandler: the dual-era HTTP entry. * - * Covers the three slot states (omitted → modern-only strict, 'stateless' → - * per-request legacy sugar, handler → bring-your-own), the handler faces, the - * per-request era write + client-identity backfill, notification routing, the - * response-mode knob, and close() teardown of the modern leg. + * Covers the two legacy postures ('stateless' — the default — and 'reject' → + * modern-only strict), the isLegacyRequest predicate and the user-land routing + * pattern that replaces the removed handler-valued legacy option, the handler + * faces, the per-request era write + client-identity backfill, notification + * routing, the response-mode knob, and close() teardown of the modern leg. */ import { Readable } from 'node:stream'; @@ -13,7 +14,7 @@ import { describe, expect, it, vi } from 'vitest'; import * as z from 'zod/v4'; import type { McpRequestContext, NodeServerResponseLike } from '../../src/server/createMcpHandler.js'; -import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { createMcpHandler, isLegacyRequest } from '../../src/server/createMcpHandler.js'; import { McpServer } from '../../src/server/mcp.js'; import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; @@ -298,11 +299,11 @@ describe('createMcpHandler — modern path', () => { }); }); -describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => { +describe("createMcpHandler — modern-only strict (legacy: 'reject')", () => { it('rejects envelope-less requests with the unsupported-protocol-version error and the supported list', async () => { const { factory, state } = testFactory(); const onerror = vi.fn(); - const handler = createMcpHandler(factory, { onerror }); + const handler = createMcpHandler(factory, { legacy: 'reject', onerror }); const response = await handler.fetch( postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'x' } } }) @@ -318,7 +319,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('rejects an envelope-less initialize naming the supported and requested versions', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const response = await handler.fetch( postRequest({ @@ -338,7 +339,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('answers GET and DELETE with 405 Method not allowed', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); for (const method of ['GET', 'DELETE']) { const response = await handler.fetch(new Request('http://localhost/mcp', { method })); @@ -353,7 +354,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('rejects batch and response-body POSTs as invalid requests', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const batch = await handler.fetch(postRequest([{ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }])); expect(batch.status).toBe(400); @@ -372,7 +373,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('answers unparseable JSON with a parse error', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const response = await handler.fetch(postRequest('{not json')); expect(response.status).toBe(400); @@ -384,7 +385,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('acknowledges and drops legacy-classified notifications (202, never dispatched)', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const response = await handler.fetch( postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }, { 'mcp-method': 'something/else' }) @@ -398,7 +399,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('routes a notification POST by the modern header when the body carries no claim', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const response = await handler.fetch( postRequest( @@ -413,7 +414,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('names the modern revisions in the strict rejection data so legacy clients can discover the endpoint era', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); const body = (await response.json()) as JSONRPCErrorBody; // The strict rejection deliberately names the modern revisions so a legacy @@ -422,10 +423,10 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => }); }); -describe('createMcpHandler — legacy: "stateless" sugar', () => { - it('serves a 2025-era client through the frozen stateless idiom with a fresh instance per request', async () => { +describe('createMcpHandler — stateless legacy fallback (the default)', () => { + it('serves a 2025-era client by default through the frozen stateless idiom with a fresh instance per request', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const initialize = await handler.fetch( postRequest({ @@ -451,9 +452,26 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { expect(state.products[0]!.server.getNegotiatedProtocolVersion()).not.toBe(MODERN_REVISION); }); + it("serves the same legacy traffic when 'stateless' is passed explicitly (the explicit value of the default)", async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const initialize = await handler.fetch( + postRequest({ + jsonrpc: '2.0', + id: 'init-2', + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy-client', version: '1.0' }, capabilities: {} } + }) + ); + expect(initialize.status).toBe(200); + expect(await initialize.text()).toContain('"protocolVersion":"2025-11-25"'); + expect(state.contexts[0]?.era).toBe('legacy'); + }); + it('answers GET and DELETE like the canonical stateless example (405, Method not allowed.)', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); for (const method of ['GET', 'DELETE']) { const response = await handler.fetch(new Request('http://localhost/mcp', { method })); @@ -466,7 +484,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { it('routes legacy notification POSTs to the legacy leg (202 acknowledged by the stateless transport)', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const response = await handler.fetch(postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' })); expect(response.status).toBe(202); @@ -476,7 +494,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { it('routes all-legacy batch arrays to the legacy leg unchanged', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const response = await handler.fetch( postRequest([ @@ -489,7 +507,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { it('hands unparseable bodies to the legacy leg so the parse error stays the legacy transport answer', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const response = await handler.fetch(postRequest('{not json')); expect(response.status).toBe(400); @@ -499,7 +517,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { it('still serves the modern path on the same endpoint (one factory, both legs)', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const modern = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'modern hello' }))); expect(modern.status).toBe(200); @@ -507,7 +525,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { expect(state.contexts[0]?.era).toBe('modern'); }); - it("reports legacy: 'stateless' leg failures through the entry's onerror instead of swallowing them", async () => { + it("reports legacy-leg failures through the entry's onerror instead of swallowing them", async () => { const onerror = vi.fn(); const handler = createMcpHandler( ctx => { @@ -516,7 +534,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { } return new McpServer({ name: 'modern-only-product', version: '1.0.0' }); }, - { legacy: 'stateless', onerror } + { onerror } ); const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); @@ -525,9 +543,9 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'legacy factory exploded' })); }); - it('keeps classifier rejections authoritative on the dual arm (pins the current -32600 cells with a slot configured)', async () => { + it('keeps classifier rejections authoritative on the dual arm (pins the current -32600 cells with the fallback active)', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); // Parsed-but-not-JSON-RPC single object: the entry's -32600, not the // legacy transport's -32700. @@ -551,7 +569,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { it('answers a legacy-direction server/discover with a plain method-not-found and zero 2026 vocabulary', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} })); expect(response.status).toBe(200); @@ -562,34 +580,143 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { }); }); -describe('createMcpHandler — legacy: bring-your-own handler', () => { - it('hands legacy-classified requests to the handler with the original bytes untouched', async () => { +describe('createMcpHandler — user-land routing with isLegacyRequest (replaces the handler-valued legacy option)', () => { + it('routes legacy traffic to an existing handler with the original bytes untouched, alongside a strict modern entry', async () => { const { factory, state } = testFactory(); const original = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }; let receivedBody: string | undefined; - let receivedParsedBody: unknown; - const byo = vi.fn(async (request: Request, options?: { parsedBody?: unknown }) => { + const existingLegacyHandler = vi.fn(async (request: Request) => { receivedBody = await request.text(); - receivedParsedBody = options?.parsedBody; - return new Response('byo-served', { status: 299 }); + return new Response('legacy-served', { status: 299 }); }); - const handler = createMcpHandler(factory, { legacy: byo }); + const modern = createMcpHandler(factory, { legacy: 'reject' }); + // The documented routing pattern: the predicate decides, the strict + // entry serves everything that is not legacy. + const route = async (request: Request): Promise => { + if (await isLegacyRequest(request)) { + return existingLegacyHandler(request); + } + return modern.fetch(request); + }; - const response = await handler.fetch(postRequest(original)); + // A claim-less 2025 request reaches the existing handler with its body + // still readable — the predicate classifies a clone, never the original. + const response = await route(postRequest(original)); expect(response.status).toBe(299); - expect(await response.text()).toBe('byo-served'); + expect(await response.text()).toBe('legacy-served'); expect(receivedBody).toBe(JSON.stringify(original)); - expect(receivedParsedBody).toEqual(original); - // GET/DELETE are method-routed to the handler too (sessionful BYO wirings own them). - const get = await handler.fetch(new Request('http://localhost/mcp', { method: 'GET' })); + // GET/DELETE are method-routed to the existing handler too (sessionful wirings own them). + const get = await route(new Request('http://localhost/mcp', { method: 'GET' })); expect(get.status).toBe(299); - // Modern envelope traffic never reaches the legacy slot. - const modern = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'hi' }))); - expect(modern.status).toBe(200); - expect(byo).toHaveBeenCalledTimes(2); + // Modern envelope traffic never reaches the legacy handler. + const modernResponse = await route(postRequest(modernToolsCall('echo', { text: 'hi' }))); + expect(modernResponse.status).toBe(200); + expect(existingLegacyHandler).toHaveBeenCalledTimes(2); expect(state.contexts.filter(ctx => ctx.era === 'modern')).toHaveLength(1); + + // A malformed modern claim is NOT legacy: it goes to the modern entry, + // which answers the validation-ladder error (-32602), never the legacy handler. + const malformed = await route( + postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION })) + ); + expect(malformed.status).toBe(400); + expect(((await malformed.json()) as JSONRPCErrorBody).error.code).toBe(-32_602); + expect(existingLegacyHandler).toHaveBeenCalledTimes(2); + }); + + it('isLegacyRequest agrees with the entry classification rung across the routing cells', async () => { + const legacyShaped: Array<{ name: string; request: () => Request }> = [ + { + name: 'claim-less request', + request: () => postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + }, + { + name: 'initialize handshake', + request: () => + postRequest({ + jsonrpc: '2.0', + id: 'init-1', + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy', version: '1.0' }, capabilities: {} } + }) + }, + { name: 'claim-less notification', request: () => postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }) }, + { name: 'GET session operation', request: () => new Request('http://localhost/mcp', { method: 'GET' }) }, + { name: 'DELETE session operation', request: () => new Request('http://localhost/mcp', { method: 'DELETE' }) }, + { + name: 'all-legacy batch array', + request: () => postRequest([{ jsonrpc: '2.0', method: 'notifications/initialized' }]) + }, + { name: 'posted JSON-RPC response', request: () => postRequest({ jsonrpc: '2.0', id: 9, result: { ok: true } }) }, + { name: 'unparseable body', request: () => postRequest('{not json') }, + { + name: 'claim-less server/discover (no envelope, classified like any other claim-less request)', + request: () => postRequest({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} }) + } + ]; + const modernShaped: Array<{ name: string; request: () => Request }> = [ + { name: 'valid modern envelope', request: () => postRequest(modernToolsCall('echo', { text: 'x' })) }, + { + name: 'enveloped server/discover probe', + request: () => postRequest({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: { _meta: ENVELOPE } }) + }, + { + name: 'envelope claiming an unsupported revision (modern path answers -32004)', + request: () => + postRequest(modernToolsCall('echo', { text: 'x' }, { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2030-01-01' })) + }, + { + name: 'malformed envelope behind a present claim (modern path answers -32602)', + request: () => postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION })) + }, + { + name: 'modern header without a claim (modern path answers -32602)', + request: () => + postRequest( + { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} }, + { 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'tools/list' } + ) + }, + { + name: 'header/body mismatch (modern path answers -32001)', + request: () => postRequest(modernToolsCall('echo', { text: 'x' }), { 'mcp-protocol-version': '2025-11-25' }) + } + ]; + + for (const { name, request } of legacyShaped) { + expect(await isLegacyRequest(request()), name).toBe(true); + } + for (const { name, request } of modernShaped) { + expect(await isLegacyRequest(request()), name).toBe(false); + } + }); + + it('leaves the request body readable and accepts a pre-parsed body without reading the stream', async () => { + const original = { jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }; + + // Body stays readable after the predicate ran (it classified a clone). + const request = postRequest(original); + expect(await isLegacyRequest(request)).toBe(true); + expect(request.bodyUsed).toBe(false); + expect(await request.text()).toBe(JSON.stringify(original)); + + // With a pre-parsed body the request stream is never touched at all. + const preParsed = postRequest(original); + expect(await isLegacyRequest(preParsed, original)).toBe(true); + expect(preParsed.bodyUsed).toBe(false); + expect(await isLegacyRequest(postRequest(modernToolsCall('echo', { text: 'x' })), modernToolsCall('echo', { text: 'x' }))).toBe( + false + ); + }); + + it("throws a TypeError at construction when a handler function is passed as the 'legacy' option", () => { + const { factory } = testFactory(); + const myExistingLegacyHandler = async (): Promise => new Response(null, { status: 200 }); + const construct = () => createMcpHandler(factory, { legacy: myExistingLegacyHandler as unknown as 'stateless' }); + expect(construct).toThrow(TypeError); + expect(construct).toThrow(/isLegacyRequest/); }); }); @@ -660,33 +787,23 @@ describe('createMcpHandler — handler faces', () => { expect(await body()).toContain('pre-parsed'); }); - it('synthesizes the forwarded body from a pre-parsed body so node-face BYO legacy handlers can read it', async () => { - const { factory } = testFactory(); - const legacyMessage = { jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }; - let receivedText: string | undefined; - let receivedContentLength: string | null = null; - let receivedTransferEncoding: string | null = null; - const byo = async (request: Request) => { - receivedText = await request.text(); - receivedContentLength = request.headers.get('content-length'); - receivedTransferEncoding = request.headers.get('transfer-encoding'); - return new Response('byo-node-served', { status: 200 }); - }; - const handler = createMcpHandler(factory, { legacy: byo }); + it('serves a pre-parsed legacy body through the node face on the default fallback (the documented express.json mounting)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); // The documented Express mounting: express.json() consumed the stream // and hands the parsed object as the third argument; the raw headers // still describe the original (already-consumed) bytes. + const legacyMessage = { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'echo', arguments: { text: 'node legacy' } } }; const { req, res, body } = nodeRequestResponse(undefined); req.headers['content-length'] = '999'; req.headers['transfer-encoding'] = 'chunked'; await handler.node(req, res, legacyMessage); expect(res.statusCode).toBe(200); - expect(await body()).toBe('byo-node-served'); - expect(receivedText).toBe(JSON.stringify(legacyMessage)); - expect(receivedContentLength).toBe(String(JSON.stringify(legacyMessage).length)); - expect(receivedTransferEncoding).toBeNull(); + expect(await body()).toContain('node legacy'); + expect(state.contexts).toHaveLength(1); + expect(state.contexts[0]?.era).toBe('legacy'); }); it('forwards req.auth from upstream middleware as pass-through authInfo on the node face', async () => { @@ -788,9 +905,9 @@ describe('createMcpHandler — close()', () => { await expect(handler.fetch(postRequest(modernToolsCall('echo', { text: 'late' })))).rejects.toThrow(/closed/); }); - it('leaves the legacy slot untouched by close() until the handler itself refuses requests', async () => { + it('leaves the legacy fallback untouched by close() until the handler itself refuses requests', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); await handler.close(); await expect(handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }))).rejects.toThrow(/closed/); }); diff --git a/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts b/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts index fff1734cb2..4aac72549e 100644 --- a/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts +++ b/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts @@ -2,8 +2,8 @@ * Wire-level continuity twin for the "Unsupported protocol version" rejection, * exercised through `createMcpHandler(factory, { legacy: 'stateless' })`. * - * The legacy slot routes 2025-era traffic through the untouched streamable HTTP - * transport, so the rejection site (and therefore the wire bytes deployed + * The legacy fallback routes 2025-era traffic through the untouched streamable + * HTTP transport, so the rejection site (and therefore the wire bytes deployed * clients sniff — see streamableHttpUnsupportedVersionLiteral.test.ts for the * go-sdk substring dependency) is the same one the standalone transport test * pins. This twin asserts the bytes hold on the sugar path itself: HTTP 400, diff --git a/packages/server/test/server/legacyStatelessFallback.test.ts b/packages/server/test/server/legacyStatelessFallback.test.ts index 8958a3516b..a125168c78 100644 --- a/packages/server/test/server/legacyStatelessFallback.test.ts +++ b/packages/server/test/server/legacyStatelessFallback.test.ts @@ -1,5 +1,5 @@ /** - * legacyStatelessFallback — the canonical `legacy` slot value, tested + * legacyStatelessFallback — the entry's default legacy serving, tested * independently of createMcpHandler: per-request stateless serving via the * frozen idiom (fresh instance + sessionIdGenerator: undefined + handleRequest). */ diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md index c72d8f2a6e..3e16961b60 100644 --- a/test/e2e/CLAUDE.md +++ b/test/e2e/CLAUDE.md @@ -62,9 +62,9 @@ entry points back via `supersededBy` (requires `removedInSpecVersion`). A covera Two transport arms host the dual-era HTTP entry (`createMcpHandler`) in process via an injected fetch, exactly like the other HTTP arms. They are era-fixed (`TRANSPORT_SPEC_VERSIONS`), so each registers cells on exactly one spec-version axis: -- `entryStateless` — the entry with the `legacy: 'stateless'` slot; the scenario's plain client is served per request through the slot. Cells run on the 2025-11-25 axis only. -- `entryModern` — the entry modern-only strict (no legacy slot); the scenario's client is put into pinned 2026-07-28 negotiation by the arm and the per-request `_meta` envelope is attached to every outgoing request/notification by the arm (a harness stop-gap until the client - emits it itself). Cells run on the 2026-07-28 axis only. +- `entryStateless` — the entry with its stateless legacy fallback (`legacy: 'stateless'`, the entry's default posture, passed explicitly so the arm stays era-pinned); the scenario's plain client is served per request through the fallback. Cells run on the 2025-11-25 axis only. +- `entryModern` — the entry hosted modern-only strict (`legacy: 'reject'`); the scenario's client is put into pinned 2026-07-28 negotiation by the arm and the per-request `_meta` envelope is attached to every outgoing request/notification by the arm (a harness stop-gap until the + client emits it itself). Cells run on the 2026-07-28 axis only. Both arms are part of the default transport list, so unrestricted requirements run through the entry automatically. When a requirement cannot run on an entry arm, annotate it with a machine-readable reason instead of bending the test: @@ -75,8 +75,9 @@ entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' Omitting `arm` excludes both arms. The reasons (`EntryExclusionReason` in types.ts) are the acceptance checklist for re-admitting cells when the corresponding entry feature lands; a coverage gate rejects annotations that would never have an effect. Requirement families that the per-request entry structurally cannot serve at all (server→client requests, sessions/resumability, standalone GET streams, subscriptions) are already expressed through their `transports` restrictions and need no annotation. -Arm-specific helpers: `wire()`'s fourth argument also accepts `entry` (createMcpHandler hosting overrides — e.g. a `responseMode` or a bring-your-own `legacy` slot value), the returned `Wired.httpLog` records every HTTP exchange (request body, status, content-type, a readable -response clone) for raw wire assertions, factories may accept the optional per-request context (`EntryServerFactory`), and `modernEnvelopeMeta()` builds the envelope for bodies that POST raw 2026-era requests through `wired.fetch`. +Arm-specific helpers: `wire()`'s fourth argument also accepts `entry` (createMcpHandler hosting overrides — e.g. a `responseMode` or a different `legacy` posture), the returned `Wired.httpLog` records every HTTP exchange (request body, status, content-type, a readable response +clone) for raw wire assertions, factories may accept the optional per-request context (`EntryServerFactory`), and `modernEnvelopeMeta()` builds the envelope for bodies that POST raw 2026-era requests through `wired.fetch`. Compositions that the entry no longer expresses through +an option (for example an existing sessionful legacy wiring routed via `isLegacyRequest` next to a strict entry) are hosted by the test body itself behind an in-process fetch — see `scenarios/hosting-entry-session.test.ts`. ## Running diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 4a5218b5f5..c79c27b3c4 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -89,9 +89,10 @@ export interface Wired extends AsyncDisposable { export interface WireOptions extends SnifferOptions { /** * createMcpHandler hosting overrides for the entry arms. Defaults: - * `{ legacy: 'stateless' }` on entryStateless (the canonical slot value) and - * modern-only strict (no legacy slot) on entryModern. `onerror` and - * `responseMode` pass through unchanged. + * `{ legacy: 'stateless' }` on entryStateless (the entry's default posture, + * passed explicitly so the arm stays pinned to the 2025 leg even if the + * default ever moves) and `{ legacy: 'reject' }` (modern-only strict) on + * entryModern. `onerror` and `responseMode` pass through unchanged. */ entry?: CreateMcpHandlerOptions; } @@ -136,13 +137,15 @@ export async function wire( // injected fetch, exactly like the other HTTP arms. The scenario factory // backs the entry directly (the entry calls it once per request with its // per-request context). `entryStateless` serves the scenario's plain - // client through the entry's `legacy: 'stateless'` slot; `entryModern` - // keeps the endpoint modern-only strict and connects the client on the - // 2026-07-28 revision (pin-mode negotiation + the per-request envelope - // stop-gap). Every HTTP exchange is recorded on `httpLog`. + // client through the entry's stateless legacy fallback (the default, + // passed explicitly to keep the arm era-pinned); `entryModern` hosts the + // endpoint modern-only strict (`legacy: 'reject'` — strict is no longer + // the entry default) and connects the client on the 2026-07-28 revision + // (pin-mode negotiation + the per-request envelope stop-gap). Every HTTP + // exchange is recorded on `httpLog`. const handler = createMcpHandler( makeServer, - transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { ...sniff.entry } + transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { legacy: 'reject', ...sniff.entry } ); const url = new URL('http://in-process/mcp'); const httpLog: RecordedHttpExchange[] = []; diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index b526e12749..d75c799c5f 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2282,15 +2282,15 @@ export const REQUIREMENTS: Record = { 'A client pinned to the 2026-07-28 revision (versionNegotiation mode pin) connects to a strict createMcpHandler endpoint without ever sending initialize — its first request is server/discover — and an enveloped tools/call round-trips.', transports: ['entryModern'], addedInSpecVersion: '2026-07-28', - note: 'Runs on the entryModern arm (modern-only strict is its default hosting); the body constructs the pinned client itself and asserts the never-initialize, discover-first and envelope clauses on the arm-recorded HTTP exchanges.' + note: "Runs on the entryModern arm (which hosts the entry strict via legacy: 'reject'; stateless legacy serving is the entry's own default); the body constructs the pinned client itself and asserts the never-initialize, discover-first and envelope clauses on the arm-recorded HTTP exchanges." }, 'typescript:hosting:entry:strict-rejects-legacy': { source: 'sdk', behavior: - 'A createMcpHandler endpoint with no legacy slot configured (modern-only strict) rejects a 2025-shaped initialize with the unsupported-protocol-version error carrying the supported modern revisions in error.data.supported; nothing is silently served on the 2025 era.', + "A createMcpHandler endpoint configured strict (legacy: 'reject') rejects a 2025-shaped initialize with the unsupported-protocol-version error carrying the supported modern revisions in error.data.supported; nothing is silently served on the 2025 era in that mode (stateless legacy serving is the entry's default and must be turned off explicitly).", transports: ['entryModern'], addedInSpecVersion: '2026-07-28', - note: 'Runs on the entryModern arm (modern-only strict is its default hosting); the 2025-shaped initialize and the plain-client connect attempt are driven against the harness-hosted endpoint via wired.fetch/wired.url. The numeric error code is asserted by message and supported-list shape only, since it shares a code with the still-disputed header/body mismatch family.' + note: "Runs on the entryModern arm (which hosts the entry strict via legacy: 'reject'); the 2025-shaped initialize and the plain-client connect attempt are driven against the harness-hosted endpoint via wired.fetch/wired.url. The numeric error code is asserted by message and supported-list shape only, since it shares a code with the still-disputed header/body mismatch family." }, 'typescript:hosting:entry:notification-202': { source: 'sdk', @@ -2318,10 +2318,10 @@ export const REQUIREMENTS: Record = { 'typescript:hosting:entry:byo-sessionful-legacy': { source: 'sdk', behavior: - 'A real sessionful legacy wiring (per-session WebStandardStreamableHTTPServerTransport instances keyed by Mcp-Session-Id) passed as the createMcpHandler legacy slot value serves the full 2025-era session lifecycle through the entry: initialize issues an Mcp-Session-Id, a follow-up POST is served on that session, GET opens the standalone SSE stream, and DELETE tears the session down (a request carrying the dead session id answers 404).', + "A real sessionful legacy wiring (per-session WebStandardStreamableHTTPServerTransport instances keyed by Mcp-Session-Id) keeps serving the full 2025-era session lifecycle alongside a strict (legacy: 'reject') createMcpHandler endpoint via explicit user-land routing on the exported isLegacyRequest predicate: initialize issues an Mcp-Session-Id, a follow-up POST is served on that session, GET opens the standalone SSE stream, and DELETE tears the session down (a request carrying the dead session id answers 404), while envelope-claiming traffic is answered by the strict modern entry and never reaches the legacy wiring.", transports: ['entryStateless'], removedInSpecVersion: '2026-07-28', - note: "The lifecycle is a statement about 2025-era serving through the bring-your-own legacy slot, so the requirement is bounded to the 2025-11-25 axis and runs on the entryStateless arm with the slot overridden via wire()'s entry.legacy option. It pins the entry routing of body-less GET and DELETE to the bring-your-own legacy slot, observed at the slot as method/status/content-type; byte-level forwarding fidelity is not asserted." + note: 'The lifecycle is a statement about 2025-era serving kept by an existing sessionful deployment, so the requirement is bounded to the 2025-11-25 axis (the entryStateless arm label). The handler-valued legacy option was removed from createMcpHandler, so the body hosts the documented replacement composition itself — isLegacyRequest in front of the existing wiring plus a strict entry — behind an in-process fetch instead of overriding the wire() arm. It pins the routing of body-less GET and DELETE to the legacy wiring, observed at the wiring as method/status/content-type; byte-level forwarding fidelity is not asserted.' }, 'typescript:hosting:entry:modern-lazy-sse-upgrade': { source: 'sdk', diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts index 40d042104b..a41a1b8bbb 100644 --- a/test/e2e/scenarios/hosting-entry-session.test.ts +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -1,36 +1,42 @@ /** - * Sessionful 2025-era serving through the dual-era HTTP entry's - * bring-your-own legacy slot, exercised on the wire() entryStateless arm with - * the slot overridden via `wire()`'s `entry.legacy` option. + * Sessionful 2025-era serving kept alive next to a strict dual-era HTTP entry + * through explicit user-land routing: the exported `isLegacyRequest` predicate + * (the entry's own classification step) decides, an existing sessionful wiring + * serves the legacy branch, and a strict (`legacy: 'reject'`) `createMcpHandler` + * serves everything else. This is the documented replacement for the removed + * handler-valued `legacy` option. * - * The legacy slot value is a real sessionful wiring — one + * The legacy wiring is real and sessionful — one * WebStandardStreamableHTTPServerTransport per session, kept in a map keyed by * the Mcp-Session-Id the transport itself issues (the documented sessionful * hosting pattern) — and a plain 2025 SDK client drives the full session - * lifecycle through the harness-hosted `createMcpHandler`: initialize issues a - * session id, a follow-up POST is served on that session, the body-less GET - * opens the standalone SSE stream, and DELETE tears the session down. Every - * exchange the slot serves is recorded as it leaves the wiring (method, status, - * content-type), so the entry's routing of GET/DELETE (no envelope, no body → - * legacy slot) to the bring-your-own handler is pinned directly; byte-level - * forwarding fidelity is not asserted here. + * lifecycle through the routed composition: initialize issues a session id, a + * follow-up POST is served on that session, the body-less GET opens the + * standalone SSE stream, and DELETE tears the session down. Every exchange the + * wiring serves is recorded as it leaves it (method, status, content-type), so + * the predicate's routing of GET/DELETE (no envelope, no body → legacy) is + * pinned directly; byte-level forwarding fidelity is not asserted here. An + * envelope-claiming probe at the end pins that modern traffic is answered by + * the strict entry, never by the legacy wiring. + * + * The composition is hosted by the test body itself (an in-process fetch in + * front of both handlers), so the wire() entry arm is not used; the matrix + * still bounds the cell to the 2025-11-25 axis via the requirement entry. */ import { randomUUID } from 'node:crypto'; -import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { Client } from '@modelcontextprotocol/client'; -import type { LegacyHttpHandler, McpHandlerRequestOptions, McpRequestContext } from '@modelcontextprotocol/server'; -import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { LegacyHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; -import { wire } from '../helpers/index.js'; +import { modernEnvelopeMeta } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; -import type { TestArgs } from '../types.js'; const LEGACY = '2025-11-25'; -/** The factory backing the modern path; this cell never drives it (the lifecycle under test is the legacy slot's). */ +/** The factory backing the strict modern entry; legacy traffic never reaches it (the lifecycle under test is the legacy wiring's). */ function modernFactory(_ctx?: McpRequestContext): McpServer { const server = new McpServer({ name: 'e2e-entry-session', version: '1.0.0' }, { capabilities: { tools: {} } }); server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ @@ -39,19 +45,19 @@ function modernFactory(_ctx?: McpRequestContext): McpServer { return server; } -verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: TestArgs) => { - // The documented sessionful wiring, passed as the bring-your-own legacy - // slot value: a fresh transport per initialize, kept in a map keyed by the - // Mcp-Session-Id it issues; later requests are routed by that header. +verifies('typescript:hosting:entry:byo-sessionful-legacy', async () => { + // The documented sessionful wiring, kept exactly as an existing deployment + // would have it: a fresh transport per initialize, kept in a map keyed by + // the Mcp-Session-Id it issues; later requests are routed by that header. const sessions = new Map(); const closedSessions: string[] = []; const sessionServers: McpServer[] = []; - async function routeSessionRequest(request: Request, options?: McpHandlerRequestOptions): Promise { + async function routeSessionRequest(request: Request): Promise { const sessionId = request.headers.get('mcp-session-id'); if (sessionId !== null) { const existing = sessions.get(sessionId); - if (existing !== undefined) return existing.handleRequest(request, options); + if (existing !== undefined) return existing.handleRequest(request); // A request for a session this wiring no longer (or never) knew — // the documented sessionful pattern answers 404. return Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 }); @@ -64,21 +70,21 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: sessions.delete(id); } }); - const server = new McpServer({ name: 'byo-session-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + const server = new McpServer({ name: 'sessionful-legacy-server', version: '1.0.0' }, { capabilities: { tools: {} } }); server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ - content: [{ type: 'text', text: `hello ${name} (byo session)` }] + content: [{ type: 'text', text: `hello ${name} (legacy session)` }] })); sessionServers.push(server); await server.connect(transport); - return transport.handleRequest(request, options); + return transport.handleRequest(request); } - // Every exchange the entry forwards to the bring-your-own slot, recorded - // as it leaves the wiring: this is what proves the GET/DELETE routing. - const slotExchanges: Array<{ method: string; status: number; contentType: string }> = []; - const sessionfulLegacy: LegacyHttpHandler = async (request, options) => { - const response = await routeSessionRequest(request, options); - slotExchanges.push({ + // Every exchange routed to the existing legacy wiring, recorded as it + // leaves the wiring: this is what proves the GET/DELETE routing. + const legacyExchanges: Array<{ method: string; status: number; contentType: string }> = []; + const sessionfulLegacy: LegacyHttpHandler = async request => { + const response = await routeSessionRequest(request); + legacyExchanges.push({ method: request.method.toUpperCase(), status: response.status, contentType: response.headers.get('content-type') ?? '' @@ -86,15 +92,25 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: return response; }; + // The documented user-land routing pattern: a strict modern entry plus the + // exported predicate in front of the existing legacy wiring. + const modern = createMcpHandler(modernFactory, { legacy: 'reject' }); + const route = async (request: Request): Promise => { + if (await isLegacyRequest(request)) { + return sessionfulLegacy(request); + } + return modern.fetch(request); + }; + const url = new URL('http://in-process/mcp'); + const fetchViaRouter = (input: URL | string, init?: RequestInit) => route(new Request(input, init)); + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); try { - // The harness hosts the entry; the bring-your-own wiring replaces the - // arm's default 'stateless' slot value. - await using wired = await wire(transport, modernFactory, client, { entry: { legacy: sessionfulLegacy } }); + await client.connect(new StreamableHTTPClientTransport(url, { fetch: fetchViaRouter })); - // initialize → the bring-your-own transport issues an Mcp-Session-Id. - // (The stateless slot never issues one, so a defined session id alone - // proves the request reached the bring-your-own wiring.) + // initialize → the sessionful wiring issues an Mcp-Session-Id. (The + // strict entry never issues one, so a defined session id alone proves + // the request was routed to the existing legacy wiring.) expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); const clientTransport = client.transport as StreamableHTTPClientTransport; const sessionId = clientTransport.sessionId; @@ -103,27 +119,27 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: // Follow-up POST on the session: served by the same per-session instance. const result = await client.callTool({ name: 'greet', arguments: { name: 'session friend' } }); - expect(result.content).toEqual([{ type: 'text', text: 'hello session friend (byo session)' }]); + expect(result.content).toEqual([{ type: 'text', text: 'hello session friend (legacy session)' }]); expect(clientTransport.sessionId).toBe(sessionId); // GET route: the client opens its standalone SSE stream after - // initialization; the entry routes the body-less GET (no envelope) to - // the legacy slot, which answers it with the stream. + // initialization; the predicate routes the body-less GET (no envelope) + // to the legacy wiring, which answers it with the stream. await vi.waitFor( () => { - const get = slotExchanges.find(exchange => exchange.method === 'GET'); - if (get === undefined) throw new Error('the standalone GET stream has not reached the legacy slot yet'); + const get = legacyExchanges.find(exchange => exchange.method === 'GET'); + if (get === undefined) throw new Error('the standalone GET stream has not reached the legacy wiring yet'); expect(get.status).toBe(200); expect(get.contentType).toContain('text/event-stream'); }, { timeout: 5000, interval: 50 } ); - // DELETE route: terminating the session goes through the entry to the - // bring-your-own transport, which tears the session down. + // DELETE route: terminating the session goes through the predicate to + // the sessionful wiring, which tears the session down. await clientTransport.terminateSession(); expect(closedSessions).toEqual([sessionId]); - const deleteExchange = slotExchanges.find(exchange => exchange.method === 'DELETE'); + const deleteExchange = legacyExchanges.find(exchange => exchange.method === 'DELETE'); expect(deleteExchange?.status).toBe(200); // Stop the client before probing the dead session so its standalone @@ -131,8 +147,8 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: await client.close(); // The dead session is gone: a POST carrying its id is answered 404 by - // the bring-your-own wiring, not silently re-served by anything else. - const stale = await wired.fetch!(wired.url!, { + // the sessionful wiring, not silently re-served by anything else. + const stale = await fetchViaRouter(url, { method: 'POST', headers: { 'content-type': 'application/json', @@ -144,11 +160,33 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: }); expect(stale.status).toBe(404); await stale.text(); - // ...and that 404 was produced by the bring-your-own wiring (the probe - // reached the slot), not synthesized by the entry or anything in front of it. - expect(slotExchanges.some(exchange => exchange.method === 'POST' && exchange.status === 404)).toBe(true); + // ...and that 404 was produced by the sessionful wiring (the probe + // reached it), not synthesized by the entry or anything in front of it. + expect(legacyExchanges.some(exchange => exchange.method === 'POST' && exchange.status === 404)).toBe(true); + + // Modern traffic is the strict entry's: an envelope-claiming request is + // answered by the modern factory and never reaches the legacy wiring. + const exchangesBeforeModernProbe = legacyExchanges.length; + const modernProbe = await fetchViaRouter(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 100, + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'router' }, + _meta: modernEnvelopeMeta({ name: 'router-probe-client', version: '1.0.0' }) + } + }) + }); + expect(modernProbe.status).toBe(200); + expect(await modernProbe.text()).toContain('hello router (modern)'); + expect(legacyExchanges).toHaveLength(exchangesBeforeModernProbe); } finally { await client.close().catch(() => {}); + await modern.close().catch(() => {}); for (const server of sessionServers) await server.close().catch(() => {}); } }); diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts index e5b3cf08d8..1958a59d0f 100644 --- a/test/e2e/scenarios/hosting-entry.test.ts +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -1,11 +1,12 @@ /** * Core cells for the dual-era HTTP entry (`createMcpHandler`), exercised - * through the wire() entry arms: `entryStateless` hosts the entry's - * `legacy: 'stateless'` slot for plain 2025-era clients (2025-11-25 axis) and - * `entryModern` hosts the modern-only strict endpoint for negotiating clients - * (2026-07-28 axis). Raw wire facts (request bodies, statuses, response bytes) - * are asserted on the arm-recorded `wired.httpLog`; raw HTTP probes go through - * `wired.fetch` so every exchange still rides the harness-hosted entry. + * through the wire() entry arms: `entryStateless` hosts the entry's stateless + * legacy fallback (the default posture) for plain 2025-era clients (2025-11-25 + * axis) and `entryModern` hosts the modern-only strict (`legacy: 'reject'`) + * endpoint for negotiating clients (2026-07-28 axis). Raw wire facts (request + * bodies, statuses, response bytes) are asserted on the arm-recorded + * `wired.httpLog`; raw HTTP probes go through `wired.fetch` so every exchange + * still rides the harness-hosted entry. */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; @@ -31,8 +32,8 @@ function greetFactory(ctx?: McpRequestContext): McpServer { } verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: TestArgs) => { - // Both cells host the same handler shape — one ctx-taking factory, legacy - // 'stateless' slot configured — and differ only in the client driving it. + // Both cells host the same handler shape — one ctx-taking factory, the + // 'stateless' legacy posture — and differ only in the client driving it. const client = transport === 'entryModern' ? new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }) @@ -41,7 +42,7 @@ verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: if (transport === 'entryStateless') { // 2025-era leg: a plain client is served per request through the - // legacy 'stateless' slot — initialize → tools/list → tools/call. + // stateless legacy fallback — initialize → tools/list → tools/call. expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); const tools = await client.listTools(); expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); @@ -68,7 +69,7 @@ verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: }); verifies('typescript:hosting:entry:pin-negotiation', async ({ transport }: TestArgs) => { - // Strict endpoint (no legacy slot — the entryModern arm default): the pinned client never needs one. + // Strict endpoint (legacy: 'reject' — the entryModern arm hosting): the pinned client never needs the legacy leg. const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); await using wired = await wire(transport, greetFactory, client); @@ -89,7 +90,7 @@ verifies('typescript:hosting:entry:pin-negotiation', async ({ transport }: TestA }); verifies('typescript:hosting:entry:strict-rejects-legacy', async ({ transport }: TestArgs) => { - // legacy omitted → modern-only strict (the entryModern arm default): no silent 2025 serving. + // legacy: 'reject' → modern-only strict (the entryModern arm hosting): no silent 2025 serving. const modernClient = new Client({ name: 'strict-modern-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); await using wired = await wire(transport, greetFactory, modernClient); diff --git a/test/e2e/types.ts b/test/e2e/types.ts index 8887f60322..cef6bd9ea8 100644 --- a/test/e2e/types.ts +++ b/test/e2e/types.ts @@ -15,11 +15,12 @@ export type Transport = (typeof ALL_TRANSPORTS)[number]; /** * The createMcpHandler entry arms: the dual-era HTTP entry hosted in process - * (injected fetch → `handler.fetch`), one arm per slot. `entryStateless` serves - * a plain 2025-era client through the entry's `legacy: 'stateless'` slot; - * `entryModern` serves a client that negotiates the 2026-07-28 revision through - * the entry's modern (per-request envelope) path. Each arm is era-fixed, so it - * registers cells on exactly one spec-version axis (see TRANSPORT_SPEC_VERSIONS). + * (injected fetch → `handler.fetch`), one arm per leg. `entryStateless` serves + * a plain 2025-era client through the entry's stateless legacy fallback (the + * default posture); `entryModern` serves a client that negotiates the + * 2026-07-28 revision through the entry's modern (per-request envelope) path. + * Each arm is era-fixed, so it registers cells on exactly one spec-version + * axis (see TRANSPORT_SPEC_VERSIONS). */ export const ENTRY_TRANSPORTS = ['entryStateless', 'entryModern'] as const satisfies readonly Transport[]; export type EntryTransport = (typeof ENTRY_TRANSPORTS)[number]; @@ -38,7 +39,7 @@ export const ALL_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const satisfies /** * Spec versions a transport arm can serve. Transports without an entry serve * every spec version on the active axis; the entry arms are era-fixed (the - * `legacy: 'stateless'` slot serves only 2025-era traffic, the modern path + * stateless legacy fallback serves only 2025-era traffic, the modern path * serves only the 2026-07-28 revision), so each registers cells on exactly one * axis. `verifies()` intersects this with a requirement's own spec-version * bounds when forming cells. diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index 61e78d13d6..e1b2693667 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -1,8 +1,8 @@ /** * createMcpHandler served over real HTTP, driven by real clients: the * 2026-capable negotiation client for the modern path and a plain 2025 client - * for the legacy slot — the three slot states on one endpoint, all backed by - * one factory. + * for the legacy fallback — both legacy postures (the stateless default and + * the strict 'reject') on one endpoint, all backed by one factory. */ import type { Server as HttpServer } from 'node:http'; import { createServer } from 'node:http'; @@ -10,14 +10,14 @@ import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; import type { CallToolResult, CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; -import { createMcpHandler, legacyStatelessFallback, McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import { afterEach, describe, expect, it } from 'vitest'; import * as z from 'zod/v4'; const MODERN = '2026-07-28'; -describe('createMcpHandler over HTTP (slot states end to end)', () => { +describe('createMcpHandler over HTTP (legacy postures end to end)', () => { const cleanups: Array<() => Promise | void> = []; afterEach(async () => { while (cleanups.length > 0) await cleanups.pop()!(); @@ -55,7 +55,7 @@ describe('createMcpHandler over HTTP (slot states end to end)', () => { }; } - it('serves the modern era to an auto-negotiating client (strict endpoint, no legacy slot)', async () => { + it('serves the modern era to an auto-negotiating client (default endpoint)', async () => { const { baseUrl } = await startEndpoint(); const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); @@ -76,16 +76,16 @@ describe('createMcpHandler over HTTP (slot states end to end)', () => { expect(result.content).toEqual([{ type: 'text', text: 'hello modern (modern)' }]); }); - it('rejects a plain 2025 client on the strict endpoint with the unsupported-protocol-version error', async () => { - const { baseUrl } = await startEndpoint(); + it("rejects a plain 2025 client on a strict (legacy: 'reject') endpoint with the unsupported-protocol-version error", async () => { + const { baseUrl } = await startEndpoint({ legacy: 'reject' }); const client = new Client({ name: 'legacy-client', version: '1.0.0' }); await expect(client.connect(new StreamableHTTPClientTransport(baseUrl))).rejects.toThrow(/Unsupported protocol version|400/); cleanups.push(() => client.close().catch(() => {})); }); - it("serves a plain 2025 client through the 'stateless' legacy slot while the modern path keeps working", async () => { - const { baseUrl } = await startEndpoint({ legacy: 'stateless' }); + it('serves a plain 2025 client through the default stateless legacy fallback while the modern path keeps working', async () => { + const { baseUrl } = await startEndpoint(); const legacyClient = new Client({ name: 'legacy-client', version: '1.0.0' }); await legacyClient.connect(new StreamableHTTPClientTransport(baseUrl)); @@ -107,17 +107,6 @@ describe('createMcpHandler over HTTP (slot states end to end)', () => { expect(modernResult.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); }); - it('serves a plain 2025 client through a bring-your-own legacy handler', async () => { - const { baseUrl } = await startEndpoint({ legacy: legacyStatelessFallback(factory) }); - - const client = new Client({ name: 'legacy-client', version: '1.0.0' }); - await client.connect(new StreamableHTTPClientTransport(baseUrl)); - cleanups.push(() => client.close()); - - const result = await client.callTool({ name: 'greet', arguments: { name: 'byo' } }); - expect(result.content).toEqual([{ type: 'text', text: 'hello byo (legacy)' }]); - }); - it('pinning the modern revision works against the entry and never sends initialize', async () => { const { baseUrl } = await startEndpoint({ legacy: 'stateless' }); From 6de84cf60d9c00e471f87b9b5098730bd8253a92 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:47:30 +0100 Subject: [PATCH 23/37] fix(server): settle a cancelled serveStdio probe so a pipelined initialize fallback cannot wedge the connection (#2317) --- packages/server/src/server/serveStdio.ts | 62 ++++++++++++++++--- .../server/test/server/serveStdio.test.ts | 35 +++++++++++ 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index e166c4298f..7511a3ece6 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -47,6 +47,7 @@ * were written for. */ import type { + CancelledNotificationParams, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, @@ -117,6 +118,16 @@ export interface StdioServerHandle { * Per-instance channel * ------------------------------------------------------------------------ */ +/** + * How long the probe-discard path waits for the probe instance to answer the + * requests it was delivered before closing it. The wait normally settles as + * soon as the DiscoverResult is handed to the wire (or immediately, when a + * delivered cancellation already settled the probe); the bound is a backstop + * so no edge can ever hold the connection's inbound pump indefinitely behind + * the discard. + */ +const DISCARD_ANSWER_TIMEOUT_MS = 3000; + /** * The transport a pinned instance is connected to: a thin channel that writes * through to the entry-owned wire transport and receives the messages the @@ -173,21 +184,45 @@ class StdioConnectionChannel implements Transport { } if (isJSONRPCRequest(message)) { this._pendingRequests.add(message.id); + } else if (isJSONRPCNotification(message) && message.method === 'notifications/cancelled') { + // By protocol contract a cancelled request may legitimately go + // unanswered (the instance aborts the in-flight handler and writes + // nothing for it), so a delivered cancellation settles the request + // it names: nothing should keep waiting for an answer that may + // never come. Non-cancelled requests still settle only when their + // answer is handed to the wire. + const cancelledId = (message.params as CancelledNotificationParams | undefined)?.requestId; + if (cancelledId !== undefined) { + this._settle(cancelledId); + } } this.onmessage?.(message, extra); } /** * Resolves once every request delivered to the instance has been answered - * through {@linkcode send} (or the channel has been closed and nothing - * further can be answered). Used by the probe-discard path so a probe - * request the entry accepted is never silently dropped. + * through {@linkcode send}, settled by a delivered cancellation, or the + * channel has been closed and nothing further can be answered. The wait is + * bounded by `timeoutMs` as a backstop so no edge can hold the caller + * indefinitely; resolves `false` only when the bound elapsed with requests + * still unanswered. Used by the probe-discard path so a probe request the + * entry accepted is never silently dropped. */ - async whenRequestsAnswered(): Promise { + async whenRequestsAnswered(timeoutMs: number): Promise { if (this._closed || this._pendingRequests.size === 0) { - return; + return true; } - await new Promise(resolve => this._drainWaiters.push(resolve)); + return await new Promise(resolve => { + const waiter = (): void => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + this._drainWaiters = this._drainWaiters.filter(pending => pending !== waiter); + resolve(false); + }, timeoutMs); + this._drainWaiters.push(waiter); + }); } async close(): Promise { @@ -405,8 +440,19 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions // the instance aborts whatever it still has in flight. Let the // in-flight DiscoverResult reach the wire before the instance is // closed; the probe instance only ever receives `server/discover`, - // whose entry-installed handler always answers promptly. - await instance.channel.whenRequestsAnswered(); + // whose entry-installed handler always answers promptly. A probe + // the client cancelled is already settled by the delivered + // cancellation (a cancelled request may go unanswered), and the + // wait is bounded as a backstop so nothing can wedge the + // connection's pump behind the discard. + const answered = await instance.channel.whenRequestsAnswered(DISCARD_ANSWER_TIMEOUT_MS); + if (!answered) { + reportError( + new Error( + `Discarded the probe instance with requests still unanswered after ${DISCARD_ANSWER_TIMEOUT_MS}ms; continuing with the fallback` + ) + ); + } await instance.product.close(); } catch (error) { reportError(toError(error)); diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts index 0ba6c3ecf5..e342918179 100644 --- a/packages/server/test/server/serveStdio.test.ts +++ b/packages/server/test/server/serveStdio.test.ts @@ -403,6 +403,41 @@ describe('server/discover probe window', () => { await handle.close(); }); + it('a pipelined cancellation of the probe followed by initialize still falls back to a working legacy session', async () => { + const { handle, request, notify, flush, eras, closed, errors } = await startEntry(); + + // The client pipelines all three messages without waiting for any + // answer: the probe, an enveloped cancellation naming the probe id + // (which aborts the in-flight discover handler, so the probe may + // legitimately never be answered), and the fallback 2025 handshake. + // The cancelled probe must not hold the connection: the handshake is + // answered and the legacy session is fully usable. + void request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + void notify({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 'probe-1', reason: 'negotiation aborted', _meta: envelope() } + }); + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + + // The probe instance was discarded and the fallback is served end to + // end by a fresh legacy instance. + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + + const list = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + await flush(); + expect(errors).toEqual([]); + + await handle.close(); + }); + it('an enveloped non-discover request after the probe still pins the modern era', async () => { const { handle, request, eras } = await startEntry(); From f8d8fc08b1923c5d5cba5d010aa0e52d321e091d Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:14:39 +0100 Subject: [PATCH 24/37] chore(core): repin the 2026-07-28 spec references at spec commit 2fb207da (#2318) --- .changeset/spec-anchor-repin-2fb207da.md | 13 ++ .../core/src/shared/inboundClassification.ts | 5 +- .../core/src/types/spec.types.2026-07-28.ts | 122 ++++++++----- .../core/src/wire/rev2026-07-28/registry.ts | 8 +- .../core/src/wire/rev2026-07-28/schemas.ts | 63 +++++-- .../elicit-sensitive-data.json | 1 - .../elicitation-complete.json | 7 - .../HeaderMismatchError/header-mismatch.json | 8 + .../corpus/fixtures/2026-07-28/manifest.json | 8 +- .../schema-twins/2026-07-28.schema.json | 164 ++++++++++-------- .../test/corpus/schema-twins/manifest.json | 6 +- packages/core/test/corpus/specCorpus.test.ts | 11 +- .../core/test/spec.types.2026-07-28.test.ts | 67 ++++--- packages/server/src/server/server.ts | 5 + test/e2e/requirements.ts | 9 +- 15 files changed, 309 insertions(+), 188 deletions(-) create mode 100644 .changeset/spec-anchor-repin-2fb207da.md delete mode 100644 packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json create mode 100644 packages/core/test/corpus/fixtures/2026-07-28/HeaderMismatchError/header-mismatch.json diff --git a/.changeset/spec-anchor-repin-2fb207da.md b/.changeset/spec-anchor-repin-2fb207da.md new file mode 100644 index 0000000000..b021ff4d77 --- /dev/null +++ b/.changeset/spec-anchor-repin-2fb207da.md @@ -0,0 +1,13 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Re-pin the 2026-07-28 draft references (spec reference types, vendored schema.json twins, example corpus) to the latest spec commit and align the 2026-era wire surface with it. Deliberate 2026-era wire behavior changes (the released 2025-11-25 surface is untouched): + +- `notifications/elicitation/complete` is no longer part of the 2026-07-28 wire registry (the draft removed the notification together with `elicitationId` on URL-mode elicitation). On connections negotiated at 2026-07-28, sending it — including via `Server.createElicitationCompletionNotifier()` — now fails locally with `SdkErrorCode.MethodNotSupportedByProtocolVersion`, and inbound copies are dropped as unknown notifications. Both remain fully supported on 2025-11-25. +- `notifications/cancelled` on 2026-era connections now parses with a revision-exact schema that requires `requestId` (the draft made it required); the notification `_meta` shape types the `io.modelcontextprotocol/subscriptionId` key. 2025-era parsing is unchanged. +- The error code `-32001` emitted for HTTP header/body mismatches is now defined by the draft schema (`HEADER_MISMATCH`); the emitted behavior is unchanged (documentation only). + +No public API surface changes; the regenerated reference artifacts are internal/test-only. diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 02e332236f..b90efbcb91 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -177,8 +177,9 @@ export type InboundClassificationOutcome = InboundLegacyRoute | InboundModernRou * with the body's classification), and the `Mcp-Method` header disagreeing * with the body method. * - * `-32001` is the SEP-2243 `HeaderMismatch` code, as asserted by the published - * conformance suite for header-validation failures. It has no + * `-32001` is the draft schema's `HEADER_MISMATCH` constant (the SEP-2243 + * `HeaderMismatch` code; the spec requires HTTP 400 for it), as also asserted + * by the published conformance suite for header-validation failures. It has no * {@linkcode ProtocolErrorCode} member because it is not part of the 2025-era * wire vocabulary; the validation ladder is its only emitter. */ diff --git a/packages/core/src/types/spec.types.2026-07-28.ts b/packages/core/src/types/spec.types.2026-07-28.ts index 1b222b9896..bf5ad3e095 100644 --- a/packages/core/src/types/spec.types.2026-07-28.ts +++ b/packages/core/src/types/spec.types.2026-07-28.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: 77cb26481e439d3437bc2bd6ccd19fcae86bb1ec + * Last updated from commit: 91b403f8d8e7bb545b51bf45b1e26c6c889cc328 * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: pnpm run fetch:spec-types 2026-07-28 @@ -110,6 +110,29 @@ export interface RequestMetaObject extends MetaObject { 'io.modelcontextprotocol/logLevel'?: LoggingLevel; } +/** + * Extends {@link MetaObject} with additional notification-specific fields. All key naming rules from `MetaObject` apply. + * + * @see {@link MetaObject} for key naming rules and reserved prefixes. + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ +export interface NotificationMetaObject extends MetaObject { + /** + * Identifies the subscription stream a notification was delivered on. The + * server MUST include this key on every notification delivered via a + * {@link SubscriptionsListenRequest | subscriptions/listen} stream, so the + * client can correlate the notification with the originating subscription. + * The key is absent on notifications not delivered via a subscription + * stream (e.g. progress notifications for an in-flight request), which is + * why it is optional here. + * + * The value is the JSON-RPC ID of the `subscriptions/listen` request that + * opened the stream. + */ + 'io.modelcontextprotocol/subscriptionId'?: RequestId; +} + /** * A progress token, used to associate progress notifications with the original request. * @@ -147,7 +170,7 @@ export interface Request { * @category Common Types */ export interface NotificationParams { - _meta?: MetaObject; + _meta?: NotificationMetaObject; } /** @internal */ @@ -357,6 +380,15 @@ export interface InternalError extends Error { code: typeof INTERNAL_ERROR; } +/** + * Error code returned when the HTTP headers of a request do not match the + * corresponding values in the request body, or required headers are + * missing or malformed. + * + * @category Errors + */ +export const HEADER_MISMATCH = -32001; + /** * Error code returned when a server requires a client capability that was * not declared in the request's `clientCapabilities`. @@ -373,6 +405,23 @@ export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; */ export const UNSUPPORTED_PROTOCOL_VERSION = -32004; +/** + * Returned when a server rejects a request because the values in the HTTP + * headers do not match the corresponding values in the request body, or + * because required headers are missing or malformed. For HTTP, the response + * status code MUST be `400 Bad Request`. + * + * @example Header mismatch + * {@includeCode ./examples/HeaderMismatchError/header-mismatch.json} + * + * @category Errors + */ +export interface HeaderMismatchError extends Omit { + error: Error & { + code: typeof HEADER_MISMATCH; + }; +} + /** * Returned when the request's protocol version is unknown to the server or * unsupported (e.g., a known experimental or draft version the server has @@ -517,9 +566,9 @@ export interface CancelledNotificationParams extends NotificationParams { /** * The ID of the request to cancel. * - * This MUST correspond to the ID of a request previously issued in the same direction. + * This MUST correspond to the ID of a request the client previously issued. */ - requestId?: RequestId; + requestId: RequestId; /** * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. @@ -528,7 +577,9 @@ export interface CancelledNotificationParams extends NotificationParams { } /** - * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * This notification is sent by the client to indicate that it is cancelling a request it previously issued. + * + * On stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequest | subscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request. * * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. * @@ -995,11 +1046,13 @@ export interface CacheableResult extends Result { * Indicates the intended scope of the cached response, analogous to HTTP * `Cache-Control: public` vs `Cache-Control: private`. * - * - `"public"`: Any client or intermediary (e.g., shared gateway, proxy) - * MAY cache the response and serve it to any user. - * - `"private"`: Only the requesting user's client MAY cache the response. - * Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached - * copy to a different user. + * - `"public"`: The response does not contain user-specific data. Any + * client or intermediary (e.g., shared gateway, caching proxy) MAY cache + * the response and serve it across authorization contexts. + * - `"private"`: The response MAY be cached and reused only within the + * same authorization context. Caches MUST NOT be shared across + * authorization contexts (e.g., a different access token requires a + * different cache). * */ cacheScope: 'public' | 'private'; @@ -1140,7 +1193,7 @@ export interface ReadResourceResultResponse extends JSONRPCResultResponse { } /** - * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This is only delivered on a {@link SubscriptionsListenRequest | subscriptions/listen} stream when the client requested it via the `resourcesListChanged` filter field. * * @example Resources list changed * {@includeCode ./examples/ResourceListChangedNotification/resources-list-changed.json} @@ -1584,7 +1637,7 @@ export interface EmbeddedResource { _meta?: MetaObject; } /** - * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This is only delivered on a {@link SubscriptionsListenRequest | subscriptions/listen} stream when the client requested it via the `promptsListChanged` filter field. * * @example Prompts list changed * {@includeCode ./examples/PromptListChangedNotification/prompts-list-changed.json} @@ -1726,7 +1779,7 @@ export interface CallToolRequest extends JSONRPCRequest { } /** - * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This is only delivered on a {@link SubscriptionsListenRequest | subscriptions/listen} stream when the client requested it via the `toolsListChanged` filter field. * * @example Tools list changed * {@includeCode ./examples/ToolListChangedNotification/tools-list-changed.json} @@ -1828,6 +1881,11 @@ export interface Tool extends BaseMetadata, Icons { * (`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other * standard validation or annotation keywords. * + * Property schemas may carry an `x-mcp-header` annotation to mirror the + * argument value into an HTTP header on the Streamable HTTP transport. See + * the Streamable HTTP transport specification for the validity and + * extraction rules. + * * Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. */ inputSchema: { $schema?: string; type: 'object'; [key: string]: unknown }; @@ -2539,7 +2597,9 @@ export interface PromptReference extends BaseMetadata { */ export interface ListRootsRequest { method: 'roots/list'; - params?: RequestParams; + params?: { + _meta?: MetaObject; + }; } /** @@ -2649,12 +2709,6 @@ export interface ElicitRequestURLParams { */ message: string; - /** - * The ID of the elicitation, which must be unique within the context of the server. - * The client MUST treat this ID as an opaque value. - */ - elicitationId: string; - /** * The URL that the user should navigate to. * @@ -2969,31 +3023,6 @@ export interface ElicitResult { content?: { [key: string]: string | number | boolean | string[] }; } -/** - * Parameters for a {@link ElicitationCompleteNotification | notifications/elicitation/complete} notification. - * - * @category `notifications/elicitation/complete` - */ -export interface ElicitationCompleteNotificationParams extends NotificationParams { - /** - * The ID of the elicitation that completed. - */ - elicitationId: string; -} - -/** - * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. - * - * @example Elicitation complete - * {@includeCode ./examples/ElicitationCompleteNotification/elicitation-complete.json} - * - * @category `notifications/elicitation/complete` - */ -export interface ElicitationCompleteNotification extends JSONRPCNotification { - method: 'notifications/elicitation/complete'; - params: ElicitationCompleteNotificationParams; -} - /* Client messages */ /** @internal */ export type ClientRequest = @@ -3009,7 +3038,7 @@ export type ClientRequest = | ListToolsRequest; /** @internal */ -export type ClientNotification = CancelledNotification | ProgressNotification; +export type ClientNotification = CancelledNotification; /** @internal */ export type ClientResult = EmptyResult; @@ -3025,7 +3054,6 @@ export type ServerNotification = | ResourceListChangedNotification | ToolListChangedNotification | PromptListChangedNotification - | ElicitationCompleteNotification | SubscriptionsAcknowledgedNotification; /** @internal */ diff --git a/packages/core/src/wire/rev2026-07-28/registry.ts b/packages/core/src/wire/rev2026-07-28/registry.ts index e361e65eff..bad5190407 100644 --- a/packages/core/src/wire/rev2026-07-28/registry.ts +++ b/packages/core/src/wire/rev2026-07-28/registry.ts @@ -4,9 +4,11 @@ * Registry membership IS the deletion story: there are NO entries for * `initialize`, `notifications/initialized`, `ping`, `logging/setLevel`, * `resources/subscribe`, `resources/unsubscribe`, - * `notifications/roots/list_changed`, the task family, or the server→client - * wire-request channel — so an era-mismatched method falls to −32601 by - * absence inbound and a typed local error outbound, with no table to forget. + * `notifications/roots/list_changed`, `notifications/elicitation/complete` + * (removed from the draft schema; 2025-11-25-only vocabulary), the task + * family, or the server→client wire-request channel — so an era-mismatched + * method falls to −32601 by absence inbound and a typed local error outbound, + * with no table to forget. * * HAND-REGISTRY SEED DECISIONS (pinned by the CI registry-diff oracle, which * fails LOUD if this list and the anchor diff ever disagree): diff --git a/packages/core/src/wire/rev2026-07-28/schemas.ts b/packages/core/src/wire/rev2026-07-28/schemas.ts index 510cd399ef..d6393d9f55 100644 --- a/packages/core/src/wire/rev2026-07-28/schemas.ts +++ b/packages/core/src/wire/rev2026-07-28/schemas.ts @@ -25,11 +25,9 @@ import { AudioContentSchema, BaseMetadataSchema, BlobResourceContentsSchema, - CancelledNotificationSchema, ClientCapabilitiesSchema, ContentBlockSchema, CursorSchema, - ElicitationCompleteNotificationSchema, IconsSchema, ImageContentSchema, ImplementationSchema, @@ -41,6 +39,7 @@ import { PromptMessageSchema, PromptReferenceSchema, PromptSchema, + RequestIdSchema, ResourceContentsSchema, ResourceListChangedNotificationSchema, ResourceSchema, @@ -434,11 +433,57 @@ export const dispatchResultSchemas: { readonly [M in Rev2026RequestMethod]: z.Zo /* ------------------------------------------------------------------------ * * Notifications. The 2026 notification set: cancelled, progress, message, * resources/updated, resources/list_changed, tools/list_changed, - * prompts/list_changed, elicitation/complete. Deleted: initialized, - * roots/list_changed, tasks/status. The shapes are revision-identical to the - * shared schemas, which are composed by reference. (The 2026-only - * subscriptions/acknowledged notification is #14 scope — see registry.ts.) + * prompts/list_changed. Deleted: initialized, roots/list_changed, + * tasks/status, elicitation/complete (removed from the draft together with + * URL-elicitation's elicitationId — both remain 2025-11-25 vocabulary only). + * The shapes are revision-identical to the shared schemas, which are + * composed by reference, EXCEPT cancelled, which forks below: this revision + * requires `requestId`. (The 2026-only subscriptions/acknowledged + * notification is #14 scope — see registry.ts.) * ------------------------------------------------------------------------ */ + +/** + * Notification `_meta` (anchor `NotificationMetaObject`): loose, with the + * subscriptions/listen demux key typed when present. Only the anchor-exact + * SHAPE is modeled here — listen delivery itself (filter gating, demux, + * teardown) is #14 scope and not implemented by this module. + */ +export const NotificationMetaSchema = z.looseObject({ + /** + * The JSON-RPC ID of the `subscriptions/listen` request that opened the + * stream a notification was delivered on; absent on notifications not + * delivered via a subscription stream. + */ + 'io.modelcontextprotocol/subscriptionId': RequestIdSchema.optional() +}); + +/** + * 2026-era `notifications/cancelled` params (anchor-exact fork): `requestId` + * is REQUIRED on this revision — the shared schema keeps it optional because + * the frozen 2025-11-25 shape declares it optional (task cancellation goes + * through `tasks/cancel` there). Requiredness is bare because no 2025-era + * traffic touches this module. + */ +export const CancelledNotificationParamsSchema = z.object({ + _meta: NotificationMetaSchema.optional(), + /** + * The ID of the request to cancel. This MUST correspond to the ID of a + * request the client previously issued. + */ + requestId: RequestIdSchema, + /** + * An optional string describing the reason for the cancellation. This MAY + * be logged or presented to the user. + */ + reason: z.string().optional() +}); + +/** 2026-era `notifications/cancelled` (see the params fork above). */ +export const CancelledNotificationSchema = z.object({ + method: z.literal('notifications/cancelled'), + params: CancelledNotificationParamsSchema +}); + /** The 2026-era notification-method set (the hand-registry seed; see the deletion list above). */ export type Rev2026NotificationMethod = | 'notifications/cancelled' @@ -447,8 +492,7 @@ export type Rev2026NotificationMethod = | 'notifications/resources/updated' | 'notifications/resources/list_changed' | 'notifications/tools/list_changed' - | 'notifications/prompts/list_changed' - | 'notifications/elicitation/complete'; + | 'notifications/prompts/list_changed'; export const notificationSchemas2026: { readonly [M in Rev2026NotificationMethod]: z.ZodType<{ method: M }> } = { 'notifications/cancelled': CancelledNotificationSchema, @@ -457,8 +501,7 @@ export const notificationSchemas2026: { readonly [M in Rev2026NotificationMethod 'notifications/resources/updated': ResourceUpdatedNotificationSchema, 'notifications/resources/list_changed': ResourceListChangedNotificationSchema, 'notifications/tools/list_changed': ToolListChangedNotificationSchema, - 'notifications/prompts/list_changed': PromptListChangedNotificationSchema, - 'notifications/elicitation/complete': ElicitationCompleteNotificationSchema + 'notifications/prompts/list_changed': PromptListChangedNotificationSchema }; /* ------------------------------------------------------------------------ * diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json index cf791ee3fe..0742eb9974 100644 --- a/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json @@ -1,6 +1,5 @@ { "mode": "url", - "elicitationId": "550e8400-e29b-41d4-a716-446655440000", "url": "https://mcp.example.com/ui/set_api_key", "message": "Please provide your API key to continue." } diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json deleted file mode 100644 index bb6d564585..0000000000 --- a/packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "jsonrpc": "2.0", - "method": "notifications/elicitation/complete", - "params": { - "elicitationId": "550e8400-e29b-41d4-a716-446655440000" - } -} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/HeaderMismatchError/header-mismatch.json b/packages/core/test/corpus/fixtures/2026-07-28/HeaderMismatchError/header-mismatch.json new file mode 100644 index 0000000000..a0f2e569c1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/HeaderMismatchError/header-mismatch.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32001, + "message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/manifest.json b/packages/core/test/corpus/fixtures/2026-07-28/manifest.json index 8aa8155edd..c0b9610818 100644 --- a/packages/core/test/corpus/fixtures/2026-07-28/manifest.json +++ b/packages/core/test/corpus/fixtures/2026-07-28/manifest.json @@ -3,7 +3,7 @@ "source": { "repo": "modelcontextprotocol/modelcontextprotocol", "path": "schema/draft/examples", - "commit": "0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65" + "commit": "2fb207da428f43a4981513ec2aef218b7533c5b3" }, "regenerate": "pnpm fetch:spec-examples --spec-dir # or [sha] to fetch from GitHub", "directoryCount": 86, @@ -85,9 +85,6 @@ "DiscoverResultResponse": [ "discover-result-response.json" ], - "ElicitationCompleteNotification": [ - "elicitation-complete.json" - ], "ElicitRequest": [ "elicitation-request.json" ], @@ -118,6 +115,9 @@ "GetPromptResultResponse": [ "get-prompt-result-response.json" ], + "HeaderMismatchError": [ + "header-mismatch.json" + ], "ImageContent": [ "image-png-content-with-annotations.json" ], diff --git a/packages/core/test/corpus/schema-twins/2026-07-28.schema.json b/packages/core/test/corpus/schema-twins/2026-07-28.schema.json index 5ce9df12e4..058eddf93a 100644 --- a/packages/core/test/corpus/schema-twins/2026-07-28.schema.json +++ b/packages/core/test/corpus/schema-twins/2026-07-28.schema.json @@ -126,7 +126,7 @@ "$ref": "#/$defs/MetaObject" }, "cacheScope": { - "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", "enum": [ "private", "public" @@ -264,7 +264,7 @@ "type": "object" }, "CancelledNotification": { - "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.", + "description": "This notification is sent by the client to indicate that it is cancelling a request it previously issued.\n\nOn stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequestsubscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.", "properties": { "jsonrpc": { "const": "2.0", @@ -289,7 +289,7 @@ "description": "Parameters for a `notifications/cancelled` notification.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/NotificationMetaObject" }, "reason": { "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", @@ -297,9 +297,12 @@ }, "requestId": { "$ref": "#/$defs/RequestId", - "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction." + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request the client previously issued." } }, + "required": [ + "requestId" + ], "type": "object" }, "ClientCapabilities": { @@ -354,14 +357,26 @@ "type": "object" }, "ClientNotification": { - "anyOf": [ - { - "$ref": "#/$defs/CancelledNotification" + "description": "This notification is sent by the client to indicate that it is cancelling a request it previously issued.\n\nOn stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequestsubscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" }, - { - "$ref": "#/$defs/ProgressNotification" + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" } - ] + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" }, "ClientRequest": { "anyOf": [ @@ -728,7 +743,7 @@ "$ref": "#/$defs/MetaObject" }, "cacheScope": { - "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", "enum": [ "private", "public" @@ -874,10 +889,6 @@ "ElicitRequestURLParams": { "description": "The parameters for a request to elicit information from the user via a URL in the client.", "properties": { - "elicitationId": { - "description": "The ID of the elicitation, which must be unique within the context of the server.\nThe client MUST treat this ID as an opaque value.", - "type": "string" - }, "message": { "description": "The message to present to the user explaining why the interaction is needed.", "type": "string" @@ -894,7 +905,6 @@ } }, "required": [ - "elicitationId", "message", "mode", "url" @@ -940,44 +950,6 @@ ], "type": "object" }, - "ElicitationCompleteNotification": { - "description": "An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request.", - "properties": { - "jsonrpc": { - "const": "2.0", - "type": "string" - }, - "method": { - "const": "notifications/elicitation/complete", - "type": "string" - }, - "params": { - "$ref": "#/$defs/ElicitationCompleteNotificationParams" - } - }, - "required": [ - "jsonrpc", - "method", - "params" - ], - "type": "object" - }, - "ElicitationCompleteNotificationParams": { - "description": "Parameters for a {@link ElicitationCompleteNotificationnotifications/elicitation/complete} notification.", - "properties": { - "_meta": { - "$ref": "#/$defs/MetaObject" - }, - "elicitationId": { - "description": "The ID of the elicitation that completed.", - "type": "string" - } - }, - "required": [ - "elicitationId" - ], - "type": "object" - }, "EmbeddedResource": { "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", "properties": { @@ -1163,6 +1135,42 @@ ], "type": "object" }, + "HeaderMismatchError": { + "description": "Returned when a server rejects a request because the values in the HTTP\nheaders do not match the corresponding values in the request body, or\nbecause required headers are missing or malformed. For HTTP, the response\nstatus code MUST be `400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32001, + "type": "integer" + } + }, + "required": [ + "code" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, "Icon": { "description": "An optionally-sized icon that can be displayed in a user interface.", "properties": { @@ -1639,7 +1647,7 @@ "$ref": "#/$defs/MetaObject" }, "cacheScope": { - "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", "enum": [ "private", "public" @@ -1728,7 +1736,7 @@ "$ref": "#/$defs/MetaObject" }, "cacheScope": { - "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", "enum": [ "private", "public" @@ -1817,7 +1825,7 @@ "$ref": "#/$defs/MetaObject" }, "cacheScope": { - "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", "enum": [ "private", "public" @@ -1881,7 +1889,12 @@ "type": "string" }, "params": { - "$ref": "#/$defs/RequestParams" + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + } + }, + "type": "object" } }, "required": [ @@ -1937,7 +1950,7 @@ "$ref": "#/$defs/MetaObject" }, "cacheScope": { - "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", "enum": [ "private", "public" @@ -2033,7 +2046,7 @@ "description": "Parameters for a `notifications/message` notification.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/NotificationMetaObject" }, "data": { "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." @@ -2194,11 +2207,21 @@ ], "type": "object" }, + "NotificationMetaObject": { + "description": "Extends {@link MetaObject} with additional notification-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/$defs/RequestId", + "description": "Identifies the subscription stream a notification was delivered on. The\nserver MUST include this key on every notification delivered via a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream, so the\nclient can correlate the notification with the originating subscription.\nThe key is absent on notifications not delivered via a subscription\nstream (e.g. progress notifications for an in-flight request), which is\nwhy it is optional here.\n\nThe value is the JSON-RPC ID of the `subscriptions/listen` request that\nopened the stream." + } + }, + "type": "object" + }, "NotificationParams": { "description": "Common params for any notification.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/NotificationMetaObject" } }, "type": "object" @@ -2369,7 +2392,7 @@ "description": "Parameters for a {@link ProgressNotificationnotifications/progress} notification.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/NotificationMetaObject" }, "message": { "description": "An optional message describing the current progress.", @@ -2465,7 +2488,7 @@ "type": "object" }, "PromptListChangedNotification": { - "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `promptsListChanged` filter field.", "properties": { "jsonrpc": { "const": "2.0", @@ -2580,7 +2603,7 @@ "$ref": "#/$defs/MetaObject" }, "cacheScope": { - "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", "enum": [ "private", "public" @@ -2836,7 +2859,7 @@ "type": "object" }, "ResourceListChangedNotification": { - "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `resourcesListChanged` filter field.", "properties": { "jsonrpc": { "const": "2.0", @@ -2964,7 +2987,7 @@ "description": "Parameters for a `notifications/resources/updated` notification.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/NotificationMetaObject" }, "uri": { "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", @@ -3174,9 +3197,6 @@ }, { "$ref": "#/$defs/LoggingMessageNotification" - }, - { - "$ref": "#/$defs/ElicitationCompleteNotification" } ] }, @@ -3314,7 +3334,7 @@ "description": "Parameters for a {@link SubscriptionsAcknowledgedNotificationnotifications/subscriptions/acknowledged} notification.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/NotificationMetaObject" }, "notifications": { "$ref": "#/$defs/SubscriptionFilter", @@ -3556,7 +3576,7 @@ }, "inputSchema": { "additionalProperties": {}, - "description": "A JSON Schema object defining the expected parameters for the tool.\n\nTool arguments are always JSON objects, so `type: \"object\"` is required at the root.\nBeyond that, any JSON Schema 2020-12 keyword may appear alongside `type` — including\ncomposition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords\n(`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other\nstandard validation or annotation keywords.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "description": "A JSON Schema object defining the expected parameters for the tool.\n\nTool arguments are always JSON objects, so `type: \"object\"` is required at the root.\nBeyond that, any JSON Schema 2020-12 keyword may appear alongside `type` — including\ncomposition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords\n(`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other\nstandard validation or annotation keywords.\n\nProperty schemas may carry an `x-mcp-header` annotation to mirror the\nargument value into an HTTP header on the Streamable HTTP transport. See\nthe Streamable HTTP transport specification for the validity and\nextraction rules.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", "properties": { "$schema": { "type": "string" @@ -3638,7 +3658,7 @@ "type": "object" }, "ToolListChangedNotification": { - "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `toolsListChanged` filter field.", "properties": { "jsonrpc": { "const": "2.0", diff --git a/packages/core/test/corpus/schema-twins/manifest.json b/packages/core/test/corpus/schema-twins/manifest.json index 1b21d36b3d..1311e833b9 100644 --- a/packages/core/test/corpus/schema-twins/manifest.json +++ b/packages/core/test/corpus/schema-twins/manifest.json @@ -2,12 +2,12 @@ "comment": "Vendored schema.json twins (TEST-ONLY conformance oracles; never bundled, never runtime). RAW upstream bytes - never reformat: each file is locked to the sha256/bytes below by schemaTwinConformance. Refresh via `pnpm fetch:schema-twins [sha]`, ATOMICALLY with the matching spec.types anchor (see packages/core/src/types/README.md lifecycle rule 4).", "source": { "repository": "modelcontextprotocol/modelcontextprotocol", - "commit": "0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65" + "commit": "2fb207da428f43a4981513ec2aef218b7533c5b3" }, "files": { "2026-07-28": { - "sha256": "afaf886c06dd8d3cbdd556d81b6483b9018112aaf7ee284fa116eca58baf54fc", - "bytes": 172822, + "sha256": "f1a3de0e522d3247f3c48e9603379d9dfc8a31b18f429e88ad816593d1d0c23c", + "bytes": 176374, "upstreamPath": "schema/draft/schema.json" }, "2025-11-25": { diff --git a/packages/core/test/corpus/specCorpus.test.ts b/packages/core/test/corpus/specCorpus.test.ts index f36586f9e7..d5bcdecdbb 100644 --- a/packages/core/test/corpus/specCorpus.test.ts +++ b/packages/core/test/corpus/specCorpus.test.ts @@ -49,6 +49,7 @@ const FIXTURES_ROOT = join(__dirname, 'fixtures'); /** JSON-RPC error-object example directories (bare `{code, message, data?}` shapes). */ const ERROR_OBJECT_DIRS = new Set([ + 'HeaderMismatchError', 'InternalError', 'InvalidParamsError', 'MethodNotFoundError', @@ -78,9 +79,13 @@ const PENDING_2026: Record = { * parse, so the entry is removed the moment the widening lands. */ const PENDING_2026_FILES: Record = { - // (empty — the SEP-2549 array-shape widenings burned when the 2026-era - // wire module landed anchor-exact Tool/CallToolResult forks; the two - // examples are real pins now.) + // The draft removed elicitationId from ElicitRequestURLParams; the SDK's + // shared schema keeps it (it is required on the frozen 2025-11-25 + // revision), and the 2026-era in-band elicitation surface that will model + // the new shape is MRTR scope (#13). Until then the upstream example + // (which carries no elicitationId) does not parse. + 'ElicitRequestURLParams/elicit-sensitive-data.json': + 'URL-mode elicitation without elicitationId is modeled with the MRTR in-band surface (SEP-2322, #13)' }; type AnyZod = z.ZodType; diff --git a/packages/core/test/spec.types.2026-07-28.test.ts b/packages/core/test/spec.types.2026-07-28.test.ts index 5bf80604c6..02ab034651 100644 --- a/packages/core/test/spec.types.2026-07-28.test.ts +++ b/packages/core/test/spec.types.2026-07-28.test.ts @@ -77,6 +77,11 @@ type WCompleteRequestParams = WCompleteRequest['params']; // PaginatedRequest in the anchor keeps `method: string` (it is the base, not // a concrete method) — composed from the derived params shape. type WPaginatedRequest = WithJSONRPCRequest<{ method: string; params: WPaginatedRequestParams }>; +// 2026-era cancelled fork (requestId required on this revision) and the +// notification `_meta` shape (anchor NotificationMetaObject). +type WCancelledNotification = z4.infer; +type WCancelledNotificationParams = z4.infer; +type WNotificationMeta = z4.infer; const sdkTypeChecks = { JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { @@ -155,14 +160,6 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CancelledNotificationParams: (sdk: SDKTypes.CancelledNotificationParams, spec: SpecTypes.CancelledNotificationParams) => { - sdk = spec; - spec = sdk; - }, - CancelledNotification: (sdk: WithJSONRPC, spec: SpecTypes.CancelledNotification) => { - sdk = spec; - spec = sdk; - }, ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: SpecTypes.ClientCapabilities) => { sdk = spec; spec = sdk; @@ -339,18 +336,6 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ElicitRequestURLParams: (sdk: SDKTypes.ElicitRequestURLParams, spec: SpecTypes.ElicitRequestURLParams) => { - sdk = spec; - spec = sdk; - }, - ElicitRequestParams: (sdk: SDKTypes.ElicitRequestParams, spec: SpecTypes.ElicitRequestParams) => { - sdk = spec; - spec = sdk; - }, - ElicitRequest: (sdk: SDKTypes.ElicitRequest, spec: SpecTypes.ElicitRequest) => { - sdk = spec; - spec = sdk; - }, PrimitiveSchemaDefinition: (sdk: SDKTypes.PrimitiveSchemaDefinition, spec: SpecTypes.PrimitiveSchemaDefinition) => { sdk = spec; spec = sdk; @@ -398,30 +383,37 @@ const sdkTypeChecks = { EnumSchema: (sdk: SDKTypes.EnumSchema, spec: SpecTypes.EnumSchema) => { sdk = spec; spec = sdk; + } +}; + +/* 2026-era wire parity checks (Q1 increment 2) — appended to sdkTypeChecks. */ +const wireParityChecks = { + Result: (sdk: WResult, spec: SpecTypes.Result) => { + sdk = spec; + spec = sdk; }, - ElicitationCompleteNotificationParams: ( - sdk: SDKTypes.ElicitationCompleteNotificationParams, - spec: SpecTypes.ElicitationCompleteNotificationParams - ) => { + // Cancelled is the one notification this era forks (requestId is REQUIRED + // on 2026-07-28; the shared schema keeps the frozen 2025-11-25 optional + // shape) — compared against the fork, not the neutral type. + CancelledNotificationParams: (sdk: WCancelledNotificationParams, spec: SpecTypes.CancelledNotificationParams) => { sdk = spec; spec = sdk; }, - ElicitationCompleteNotification: ( - sdk: WithJSONRPC, - spec: SpecTypes.ElicitationCompleteNotification - ) => { + CancelledNotification: (sdk: WithJSONRPC, spec: SpecTypes.CancelledNotification) => { sdk = spec; spec = sdk; }, - ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { + // The 2026 client-sent notification set is exactly `notifications/cancelled` + // (progress is server→client only on this revision), so the union compares + // against the cancelled fork; HTTP-side cancellation semantics (close the + // stream) are #14 scope and not asserted here. + ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { sdk = spec; spec = sdk; - } -}; - -/* 2026-era wire parity checks (Q1 increment 2) — appended to sdkTypeChecks. */ -const wireParityChecks = { - Result: (sdk: WResult, spec: SpecTypes.Result) => { + }, + // Notification `_meta` (anchor NotificationMetaObject): the typed + // subscriptions/listen demux key — shape only; listen delivery is #14. + NotificationMetaObject: (sdk: WNotificationMeta, spec: SpecTypes.NotificationMetaObject) => { sdk = spec; spec = sdk; }, @@ -629,6 +621,10 @@ const FEATURE_OWNED_PENDING_2026: Record = { CreateMessageRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', CreateMessageRequestParams: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', CreateMessageResult: 'M4.1 MRTR (#13) — in-band response shape', + ElicitRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', + ElicitRequestParams: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', + ElicitRequestURLParams: + 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026; the draft also removed elicitationId from the URL-mode shape', ElicitResult: 'M4.1 MRTR (#13) — in-band response shape', ListRootsRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', ListRootsResult: 'M4.1 MRTR (#13) — in-band response shape', @@ -653,6 +649,7 @@ const FEATURE_OWNED_PENDING_2026: Record = { ServerNotification: 'M6.1 subscriptions/listen (#14) — the union gains the acknowledged notification', // M1.2 validation ladder (#8): the per-code error response envelopes: + HeaderMismatchError: 'M1.2 validation ladder (#8)', MissingRequiredClientCapabilityError: 'M1.2 validation ladder (#8)', UnsupportedProtocolVersionError: 'M1.2 validation ladder (#8)' }; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index a0cd296ddb..5e3633a6ff 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -755,6 +755,11 @@ export class Server extends Protocol { * Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete` * notification for the specified elicitation ID. * + * The notification (and the `elicitationId` it references) exists only on protocol revision + * 2025-11-25 — the 2026-07-28 draft removed both. On a connection negotiated at 2026-07-28 the + * returned callback rejects with a typed local error before anything reaches the transport + * (the method is not part of that revision's wire registry). + * * @param elicitationId The ID of the elicitation to mark as complete. * @param options Optional notification options. Useful when the completion notification should be related to a prior request. * @returns A function that emits the completion notification when awaited. diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index d75c799c5f..2c919fbc2d 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1060,7 +1060,14 @@ export const REQUIREMENTS: Record = { 'elicitation:url:complete-unknown-ignored': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#completion-notifications-for-url-mode-elicitation', behavior: - 'The client ignores an elicitation/complete notification referencing an unknown or already-completed elicitationId without error.' + 'The client ignores an elicitation/complete notification referencing an unknown or already-completed elicitationId without error.', + entryExclusions: [ + { + arm: 'entryModern', + reason: 'method-not-in-modern-registry', + note: 'notifications/elicitation/complete was removed from the 2026-07-28 draft; on that revision the client drops it as an unknown notification (the row asserts ignored-without-error against received copies, which never arrive)' + } + ] }, 'elicitation:url:required-error': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-elicitation-required-error', From dab933d8102cfd4b630878af7c8fbbfdafe94bb2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:03:11 +0100 Subject: [PATCH 25/37] =?UTF-8?q?feat:=20multi=20round-trip=20requests=20?= =?UTF-8?q?=E2=80=94=20neutral=20contract=20and=20client=20auto-fulfilment?= =?UTF-8?q?=20engine=20(#2313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/mrtr-client-engine.md | 12 + packages/client/src/client/client.ts | 116 +++++- packages/client/src/index.ts | 6 + .../test/client/inputRequiredEngine.test.ts | 374 ++++++++++++++++++ .../test/client/modernEraInboundDrop.test.ts | 36 ++ packages/core/src/errors/sdkErrors.ts | 9 + packages/core/src/exports/public/index.ts | 1 + packages/core/src/index.ts | 3 + packages/core/src/shared/inputRequired.ts | 186 +++++++++ .../core/src/shared/inputRequiredDriver.ts | 286 ++++++++++++++ .../core/src/shared/inputRequiredEngine.ts | 238 +++++++++++ packages/core/src/shared/protocol.ts | 162 +++++++- packages/core/src/types/guards.ts | 19 + packages/core/src/types/types.ts | 92 +++++ packages/core/src/wire/codec.ts | 14 + packages/core/src/wire/rev2025-11-25/codec.ts | 9 + packages/core/src/wire/rev2026-07-28/codec.ts | 30 +- .../src/wire/rev2026-07-28/inputRequired.ts | 83 ++++ .../core/src/wire/rev2026-07-28/schemas.ts | 142 ++++++- packages/core/test/corpus/specCorpus.test.ts | 13 +- .../core/test/shared/inputRequired.test.ts | 89 +++++ .../test/shared/inputRequiredDriver.test.ts | 306 ++++++++++++++ .../test/shared/inputRequiredEngine.test.ts | 77 ++++ .../test/shared/inputRequiredFunnel.test.ts | 162 ++++++++ .../core/test/spec.types.2026-07-28.test.ts | 176 +++++++-- .../core/test/types/errorSurfacePins.test.ts | 1 + test/e2e/scenarios/raw-result-type.test.ts | 6 +- 27 files changed, 2571 insertions(+), 77 deletions(-) create mode 100644 .changeset/mrtr-client-engine.md create mode 100644 packages/client/test/client/inputRequiredEngine.test.ts create mode 100644 packages/core/src/shared/inputRequired.ts create mode 100644 packages/core/src/shared/inputRequiredDriver.ts create mode 100644 packages/core/src/shared/inputRequiredEngine.ts create mode 100644 packages/core/src/wire/rev2026-07-28/inputRequired.ts create mode 100644 packages/core/test/shared/inputRequired.test.ts create mode 100644 packages/core/test/shared/inputRequiredDriver.test.ts create mode 100644 packages/core/test/shared/inputRequiredEngine.test.ts create mode 100644 packages/core/test/shared/inputRequiredFunnel.test.ts diff --git a/.changeset/mrtr-client-engine.md b/.changeset/mrtr-client-engine.md new file mode 100644 index 0000000000..451f717e10 --- /dev/null +++ b/.changeset/mrtr-client-engine.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +Add the client side of multi round-trip requests (protocol revision 2026-07-28, SEP-2322). The neutral `InputRequest`/`InputResponse`/`InputRequests`/`InputResponses`/`InputRequiredResult` types and the `isInputRequiredResult()` guard ship as the neutral surface (the +`inputRequired()` builder family and the `acceptedContent()` reader are exported by the server package as part of the server-side change); the 2026-07-28 wire codec models the in-band vocabulary (embedded requests and bare responses) and the retry-channel request fields. On the +client, an `input_required` answer to `tools/call`, `prompts/get`, or `resources/read` on a 2026-07-28 connection is now fulfilled automatically by default: the embedded requests are dispatched to the client's already-registered elicitation/sampling/roots handlers, and the +original call is retried with the collected `inputResponses`, a byte-exact echo of the opaque `requestState`, and a fresh request id, up to `inputRequired.maxRounds` rounds (default 10; exhaustion raises a typed `InputRequiredRoundsExceeded` error carrying the last result). +`client.callTool()` and its siblings keep returning their plain result types. `ClientOptions.inputRequired` (`autoFulfill`, `maxRounds`) configures the driver; manual mode is `autoFulfill: false` plus the per-call `allowInputRequired: true` request option and the +`withInputRequired()` schema wrapper. Retried requests surface their `inputResponses` to server handlers as bare response objects — entries in a wrapped `{method, result}` shape are dropped and reported via `ctx.mcpReq.droppedInputResponseKeys`. 2025-era behavior is unchanged: +the legacy wire has no `input_required` vocabulary and the legacy server-to-client request flow is untouched. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 0831efb13f..0d9d40c6af 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -14,6 +14,7 @@ import type { GetPromptRequest, GetPromptResult, Implementation, + InputRequiredOptions, JSONRPCNotification, JSONRPCRequest, JsonSchemaType, @@ -31,14 +32,17 @@ import type { ListToolsResult, LoggingLevel, MessageExtraInfo, + NonCompleteResultFlow, NotificationMethod, ProtocolOptions, ReadResourceRequest, ReadResourceResult, RequestMethod, RequestOptions, + ResolvedInputRequiredDriverConfig, Result, ServerCapabilities, + StandardSchemaV1, SubscribeRequest, Tool, Transport, @@ -59,6 +63,8 @@ import { Protocol, ProtocolError, ProtocolErrorCode, + resolveInputRequiredDriverConfig, + runInputRequiredFlow, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; @@ -178,6 +184,31 @@ export type ClientOptions = ProtocolOptions & { */ versionNegotiation?: VersionNegotiationOptions; + /** + * Multi-round-trip auto-fulfilment (protocol revision 2026-07-28). + * + * On the 2026-07-28 era, servers obtain client input (elicitation, + * sampling, roots) by answering `tools/call`, `prompts/get`, or + * `resources/read` with an `input_required` result instead of sending a + * server→client request. By default the client fulfils those embedded + * requests automatically through the SAME handlers registered via + * {@linkcode Client.setRequestHandler | setRequestHandler} (e.g. + * `elicitation/create`), then retries the original call with the + * collected `inputResponses` and a byte-exact echo of the opaque + * `requestState`, on a fresh request id, up to `maxRounds` rounds. + * `client.callTool()` (and its siblings) keep returning their plain + * result type — the interactive rounds happen inside the call. + * + * Set `autoFulfill: false` for manual mode: an `input_required` response + * then surfaces as a typed error unless the individual call passes + * `allowInputRequired: true` (pair it with `withInputRequired()` on the + * explicit-schema path to type both outcomes). + * + * Has no effect on 2025-era connections, which have no `input_required` + * vocabulary. + */ + inputRequired?: InputRequiredOptions; + /** * Configure handlers for list changed notifications (tools, prompts, resources). * @@ -253,6 +284,7 @@ export class Client extends Protocol { private _enforceStrictCapabilities: boolean; private _versionNegotiation?: VersionNegotiationOptions; private _supportedProtocolVersionsOption?: string[]; + private _inputRequiredDriverConfig: ResolvedInputRequiredDriverConfig; /** * Initializes this client with the given name and version information. @@ -267,6 +299,9 @@ export class Client extends Protocol { this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; this._versionNegotiation = options?.versionNegotiation; this._supportedProtocolVersionsOption = options?.supportedProtocolVersions; + // Multi-round-trip auto-fulfilment driver (2026-07-28): on by default, + // configurable via ClientOptions.inputRequired. + this._inputRequiredDriverConfig = resolveInputRequiredDriverConfig(options?.inputRequired); // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -299,6 +334,42 @@ export class Client extends Protocol { return undefined; } + /** + * Wires the multi-round-trip auto-fulfilment engine (protocol revision + * 2026-07-28) into the response funnel: an `input_required` answer is + * fulfilled through the registered elicitation/sampling/roots handlers + * and the original request retried via `flow.retry`, up to + * `inputRequired.maxRounds` rounds. With auto-fulfilment disabled the + * response surfaces as a typed error steering to manual mode. + */ + protected override _resolveNonCompleteResult( + decoded: { kind: 'input_required'; inputRequests: Record; requestState?: string }, + flow: NonCompleteResultFlow + ): Promise { + if (!this._inputRequiredDriverConfig.autoFulfill) { + return Promise.reject( + new SdkError( + SdkErrorCode.UnsupportedResultType, + `Unsupported result type 'input_required' for ${flow.request.method}: ` + + `multi-round-trip auto-fulfilment is not enabled on this instance — ` + + `pass allowInputRequired: true to handle it manually, or enable inputRequired.autoFulfill`, + { resultType: 'input_required', method: flow.request.method } + ) + ); + } + return runInputRequiredFlow( + { + getRequestHandler: method => + this._getRequestHandler(method) as ((request: JSONRPCRequest, ctx: unknown) => Promise) | undefined, + buildContext: baseCtx => this.buildContext(baseCtx, undefined), + sessionId: this.transport?.sessionId + }, + this._inputRequiredDriverConfig, + decoded, + flow + ); + } + /** * Set up handlers for list changed notifications based on config and server capabilities. * This should only be called after initialization when server capabilities are known. @@ -352,14 +423,16 @@ export class Client extends Protocol { if (method === 'elicitation/create') { return async (request, ctx) => { // Era-exact validation: the schemas are resolved from the - // instance era at dispatch time (the era gate guarantees the - // method exists on the serving era before we get here). + // instance era at dispatch time. On the 2025 era the method + // is a wire request (registry schemas); on the 2026 era it is + // in-band vocabulary reached only via the multi-round-trip + // driver, so the in-band schemas apply. const codec = codecForVersion(this._negotiatedProtocolVersion); - const elicitRequestSchema = codec.requestSchema('elicitation/create'); + const elicitRequestSchema = codec.requestSchema('elicitation/create') ?? codec.inputRequestSchema('elicitation/create'); // The era registry entry IS the plain ElicitResult schema // (the result map is aligned to the typed map — no widened // unions), so no narrower surface is needed. - const elicitResultSchema = codec.resultSchema('elicitation/create'); + const elicitResultSchema = codec.resultSchema('elicitation/create') ?? codec.inputResponseSchema('elicitation/create'); if (!elicitRequestSchema || !elicitResultSchema) { throw new ProtocolError(ProtocolErrorCode.InternalError, 'No wire schema for elicitation/create in the resolved era'); } @@ -416,9 +489,13 @@ export class Client extends Protocol { if (method === 'sampling/createMessage') { return async (request, ctx) => { - // Era-exact validation via the instance era (see above). + // Era-exact validation via the instance era (see above): wire + // request schema on the 2025 era, in-band schema on the 2026 + // era (where sampling reaches the handler only as an embedded + // input request). const codec = codecForVersion(this._negotiatedProtocolVersion); - const samplingRequestSchema = codec.requestSchema('sampling/createMessage'); + const wireSamplingRequestSchema = codec.requestSchema('sampling/createMessage'); + const samplingRequestSchema = wireSamplingRequestSchema ?? codec.inputRequestSchema('sampling/createMessage'); if (!samplingRequestSchema) { throw new ProtocolError( ProtocolErrorCode.InternalError, @@ -436,13 +513,28 @@ export class Client extends Protocol { const result = await handler(request, ctx); - // The result schema depends on the REQUEST params (tools vs - // no tools) — something a method-keyed registry entry cannot - // express, so the pair is picked here. The era gate keeps - // this era-correct: sampling/createMessage is only ever - // dispatched on an era whose registry defines it. + // The result-side schema mirrors the request-side selection so + // both stay on the same era's vocabulary. On the 2025 era the + // schema depends on the REQUEST params (tools vs no tools) — + // something a method-keyed registry entry cannot express, so + // the pair is picked here. When the request schema came from + // the in-band fallback (2026 era, where sampling reaches the + // handler only as an embedded input request), the embedded + // response schema applies — it covers plain and tool-bearing + // responses alike. const hasTools = params.tools || params.toolChoice; - const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; + const resultSchema = + wireSamplingRequestSchema === undefined + ? codec.inputResponseSchema('sampling/createMessage') + : hasTools + ? CreateMessageResultWithToolsSchema + : CreateMessageResultSchema; + if (!resultSchema) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + 'No result schema for sampling/createMessage in the resolved era' + ); + } const validationResult = parseSchema(resultSchema, result); if (!validationResult.success) { const errorMessage = diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 42fc132c2a..678bb4d45d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -75,5 +75,11 @@ export { StreamableHTTPClientTransport } from './client/streamableHttp.js'; // runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) export { fromJsonSchema } from './fromJsonSchema.js'; +// Multi-round-trip requests (protocol revision 2026-07-28): the client-side +// auto-fulfilment knobs (ClientOptions.inputRequired) and the manual-mode +// schema wrapper for callers that opt out of auto-fulfilment per call. +export type { InputRequiredOptions } from '@modelcontextprotocol/core'; +export { withInputRequired } from '@modelcontextprotocol/core'; + // re-export curated public API from core export * from '@modelcontextprotocol/core/public'; diff --git a/packages/client/test/client/inputRequiredEngine.test.ts b/packages/client/test/client/inputRequiredEngine.test.ts new file mode 100644 index 0000000000..f688e57e1a --- /dev/null +++ b/packages/client/test/client/inputRequiredEngine.test.ts @@ -0,0 +1,374 @@ +/** + * The client-side multi-round-trip engine end to end against a scripted + * modern (2026-07-28) server: auto-fulfilment via the already-registered + * handlers, fresh request ids per leg, byte-exact requestState echo, bare + * (never wrapped) inputResponses, multi-round flows, the round cap, manual + * mode, and the synthesized handler context contract. + */ +import type { ElicitResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { InMemoryTransport, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { ClientOptions } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +const ELICIT_ENTRY = { + method: 'elicitation/create', + params: { mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', properties: { name: { type: 'string' } } } } +}; + +interface ScriptedServer { + clientTx: InMemoryTransport; + written: JSONRPCMessage[]; + toolCalls: JSONRPCRequest[]; +} + +/** + * Scripted modern server: negotiates 2026-07-28 via server/discover and + * answers tools/call from the provided responder. + */ +async function scriptedModernServer(respondToToolCall: (request: JSONRPCRequest, call: number) => unknown): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + const toolCalls: JSONRPCRequest[] = []; + serverTx.onmessage = message => { + written.push(message); + const request = message as JSONRPCRequest; + if (request.id === undefined) return; + if (request.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'scripted-mrtr-server', version: '1.0.0' } + } + }); + return; + } + if (request.method === 'tools/call') { + toolCalls.push(request); + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: respondToToolCall(request, toolCalls.length) + } as Parameters[0]); + } + }; + await serverTx.start(); + return { clientTx, written, toolCalls }; +} + +function makeClient(options?: ClientOptions): Client { + return new Client( + { name: 'mrtr-engine-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, capabilities: { elicitation: { form: {} } }, ...options } + ); +} + +const COMPLETE_RESULT = { resultType: 'complete', content: [{ type: 'text', text: 'deployed' }] }; + +describe('auto-fulfilment (default on)', () => { + it('fulfils an elicitation via the registered handler and retries with a fresh id, bare responses, and a byte-exact requestState echo', async () => { + const { clientTx, toolCalls } = await scriptedModernServer((request, call) => { + if (call === 1) { + return { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY }, requestState: 'opaque-✓-state' }; + } + // The retry must carry the responses; echo checked below. + expect(request.params).toMatchObject({ name: 'deploy' }); + return COMPLETE_RESULT; + }); + + const client = makeClient(); + const handled: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + handled.push(request.params); + return { action: 'accept', content: { name: 'octocat' } } satisfies ElicitResult; + }); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]); + expect('resultType' in result).toBe(false); + + // The handler saw the embedded request params. + expect(handled).toHaveLength(1); + expect(handled[0]).toMatchObject({ mode: 'form', message: 'What is your name?' }); + + // Two independent wire legs with fresh (different) ids. + expect(toolCalls).toHaveLength(2); + expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id); + + // The retry carries the original params, the BARE response (no + // {method, result} wrapper), and the byte-exact requestState echo. + const retryParams = toolCalls[1]!.params as Record; + expect(retryParams.name).toBe('deploy'); + expect(retryParams.arguments).toEqual({ env: 'prod' }); + expect(retryParams.inputResponses).toEqual({ github_login: { action: 'accept', content: { name: 'octocat' } } }); + expect(retryParams.requestState).toBe('opaque-✓-state'); + + await client.close(); + }); + + it('keeps the loop going across multiple rounds and omits requestState when a round carries none', async () => { + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => { + if (call === 1) { + return { resultType: 'input_required', inputRequests: { first: ELICIT_ENTRY }, requestState: 'state-1' }; + } + if (call === 2) { + return { resultType: 'input_required', inputRequests: { second: ELICIT_ENTRY } }; + } + return COMPLETE_RESULT; + }); + + const client = makeClient(); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'deploy', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]); + expect(toolCalls).toHaveLength(3); + + const secondRetry = toolCalls[2]!.params as Record; + expect(Object.keys(secondRetry.inputResponses as Record)).toEqual(['second']); + // The second input_required carried no requestState — the retry MUST NOT include one. + expect('requestState' in secondRetry).toBe(false); + + await client.close(); + }); + + it('exhausting the round cap raises the typed rounds-exceeded error carrying the last result', async () => { + const { clientTx, toolCalls } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { again: ELICIT_ENTRY }, + requestState: 'still-going' + })); + + const client = makeClient({ inputRequired: { maxRounds: 2 } }); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + const outcome = client.callTool({ name: 'deploy', arguments: {} }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + const typed = error as SdkError; + expect(typed.code).toBe(SdkErrorCode.InputRequiredRoundsExceeded); + expect(typed.data).toMatchObject({ rounds: 2, lastResult: { requestState: 'still-going' } }); + return true; + }); + // Cap 2 ⇒ the original call plus exactly two retries reached the wire... no: + // the cap counts ROUNDS (retries); round 3 is never started, so the wire + // saw the original call + 2 retries. + expect(toolCalls).toHaveLength(3); + + await client.close(); + }); + + it('fails the call with a typed error when a required handler is not registered (reject, do not guess)', async () => { + const { clientTx } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { sample: { method: 'sampling/createMessage', params: { messages: [], maxTokens: 5 } } } + })); + + const client = makeClient(); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.CapabilityNotSupported, + data: { key: 'sample', method: 'sampling/createMessage' } + }); + + await client.close(); + }); + + it('validates a forked, tool-bearing embedded sampling response against the 2026 in-band response schema', async () => { + const SAMPLING_WITH_TOOLS_ENTRY = { + method: 'sampling/createMessage', + params: { + messages: [{ role: 'user', content: { type: 'text', text: 'What is the weather in Berlin?' } }], + maxTokens: 200, + tools: [{ name: 'get_weather', inputSchema: { type: 'object', properties: { city: { type: 'string' } } } }] + } + }; + // Forked 2026 vocabulary: array content with a tool_use block and a + // tool_result block whose structuredContent is NOT an object (the + // 2026 anchor allows any value there; the 2025 result schemas do not). + // This pins that the embedded response is validated against the era's + // in-band response schema, mirroring the request-side selection. + const TOOL_BEARING_RESPONSE = { + model: 'test-model-1', + role: 'assistant' as const, + stopReason: 'toolUse', + content: [ + { type: 'tool_use' as const, name: 'get_weather', id: 'call-1', input: { city: 'Berlin' } }, + { + type: 'tool_result' as const, + toolUseId: 'call-0', + content: [{ type: 'text' as const, text: '21°C' }], + structuredContent: 21 + } + ] + }; + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => + call === 1 ? { resultType: 'input_required', inputRequests: { weather: SAMPLING_WITH_TOOLS_ENTRY } } : COMPLETE_RESULT + ); + + const client = makeClient({ capabilities: { sampling: { tools: {} } } }); + // The non-object structuredContent is deliberately outside the 2025 + // result types (it is the 2026 fork) — hence the cast. + client.setRequestHandler('sampling/createMessage', async () => TOOL_BEARING_RESPONSE as never); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'deploy', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]); + + // The retry carries the bare tool-bearing response unchanged. + expect(toolCalls).toHaveLength(2); + const retryParams = toolCalls[1]!.params as { inputResponses?: Record }; + expect(retryParams.inputResponses?.weather).toEqual(TOOL_BEARING_RESPONSE); + + await client.close(); + }); + + it('counts the first wire leg against maxTotalTimeout (the budget bounds the whole flow)', async () => { + let now = 1_000_000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + try { + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => { + // The first leg alone "takes" longer than the whole-flow budget. + now += 10_000; + return call === 1 ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY } } : COMPLETE_RESULT; + }); + + const client = makeClient(); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + await expect( + client.callTool({ name: 'deploy', arguments: {} }, { timeout: 60_000, maxTotalTimeout: 5_000 }) + ).rejects.toMatchObject({ code: SdkErrorCode.RequestTimeout, data: { maxTotalTimeout: 5_000 } }); + // The flow failed before any retry reached the wire. + expect(toolCalls).toHaveLength(1); + + await client.close(); + } finally { + nowSpy.mockRestore(); + } + }); + + it('fails fast with a typed error when input_required carries neither inputRequests nor requestState', async () => { + const { clientTx, toolCalls } = await scriptedModernServer(() => ({ resultType: 'input_required' })); + + const client = makeClient(); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.InvalidResult, + data: { method: 'tools/call', violation: 'input-required-missing-both' } + }); + // Fail fast: the original params are never resent until the cap runs out. + expect(toolCalls).toHaveLength(1); + + await client.close(); + }); + + it('fails the call with a typed error for an unknown embedded request kind', async () => { + const { clientTx } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { weird: { method: 'tasks/create', params: {} } } + })); + + const client = makeClient(); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.InvalidResult, + data: { key: 'weird', method: 'tasks/create' } + }); + + await client.close(); + }); + + it('gives the embedded handler the synthesized context: correlation-only id, chained signal, send/notify unavailable', async () => { + const { clientTx } = await scriptedModernServer((_request, call) => + call === 1 ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY } } : COMPLETE_RESULT + ); + + const client = makeClient(); + const seenCtx: unknown[] = []; + client.setRequestHandler('elicitation/create', async (_request, ctx) => { + seenCtx.push(ctx); + expect(ctx.mcpReq.id).toBe('github_login'); + expect(ctx.mcpReq.method).toBe('elicitation/create'); + expect(ctx.mcpReq.signal.aborted).toBe(false); + expect(() => ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 1, progress: 1 } })).toThrow( + /not available/ + ); + expect(() => ctx.mcpReq.send({ method: 'ping' })).toThrow(/not available/); + return { action: 'accept', content: { name: 'octocat' } }; + }); + await client.connect(clientTx); + + await client.callTool({ name: 'deploy', arguments: {} }); + expect(seenCtx).toHaveLength(1); + + await client.close(); + }); +}); + +describe('manual mode', () => { + it('autoFulfill: false surfaces input_required as a typed error (no retries hit the wire)', async () => { + const { clientTx, toolCalls } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { github_login: ELICIT_ENTRY } + })); + + const client = makeClient({ inputRequired: { autoFulfill: false } }); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.UnsupportedResultType, + data: { resultType: 'input_required', method: 'tools/call' } + }); + expect(toolCalls).toHaveLength(1); + + await client.close(); + }); + + it('allowInputRequired: true hands the input-required value back to the caller, who can retry manually', async () => { + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => + call === 1 + ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY }, requestState: 'manual-state' } + : COMPLETE_RESULT + ); + + const client = makeClient({ inputRequired: { autoFulfill: false } }); + await client.connect(clientTx); + + const first = (await client.callTool({ name: 'deploy', arguments: {} }, { allowInputRequired: true })) as unknown as Record< + string, + unknown + >; + expect(first.resultType).toBe('input_required'); + expect(first.requestState).toBe('manual-state'); + + // The caller drives the retry itself: same params + responses + echo. + const second = await client.callTool({ + name: 'deploy', + arguments: {}, + inputResponses: { github_login: { action: 'accept', content: { name: 'octocat' } } }, + requestState: first.requestState as string + } as Parameters[0]); + expect(second.content).toEqual([{ type: 'text', text: 'deployed' }]); + expect(toolCalls).toHaveLength(2); + expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id); + + await client.close(); + }); +}); diff --git a/packages/client/test/client/modernEraInboundDrop.test.ts b/packages/client/test/client/modernEraInboundDrop.test.ts index c23f5d1614..fbdc1f0af0 100644 --- a/packages/client/test/client/modernEraInboundDrop.test.ts +++ b/packages/client/test/client/modernEraInboundDrop.test.ts @@ -90,6 +90,42 @@ describe('client inbound-drop on modern-era connections (TS-01)', () => { await client.close(); }); + it('refuses a wire elicitation/create request on a modern connection even when an elicitation handler is registered (the in-band vocabulary grants no wire dispatch)', async () => { + const { clientTx, serverTx, written } = await scriptedServerSide('modern'); + const client = new Client( + { name: 'drop-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } + ); + const handled: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + handled.push(request.params); + return { action: 'accept', content: {} }; + }); + const errors: Error[] = []; + client.onerror = error => void errors.push(error); + await client.connect(clientTx); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const before = written.length; + // elicitation/create exists on the 2026-07-28 era only as in-band + // (embedded) vocabulary inside input_required results. A wire request + // for it must never reach the registered handler or be answered with a + // result — the era gate is not bypassed by the in-band schema fallback. + await serverTx.send({ + jsonrpc: '2.0', + id: 'rogue-elicit-1', + method: 'elicitation/create', + params: { mode: 'form', message: 'Name?', requestedSchema: { type: 'object', properties: {} } } + }); + await flush(); + + expect(handled).toHaveLength(0); + expect(written).toHaveLength(before); + expect(errors.some(error => error.message.includes('Dropped inbound request'))).toBe(true); + + await client.close(); + }); + it('keeps answering inbound requests on legacy-era connections (control arm)', async () => { const { clientTx, serverTx, written } = await scriptedServerSide('legacy'); const client = new Client({ name: 'legacy-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index eec7596cc5..ac9435102e 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -34,6 +34,15 @@ export enum SdkErrorCode { * `input_required`. The kind is carried in `data.resultType`. */ UnsupportedResultType = 'UNSUPPORTED_RESULT_TYPE', + /** + * The multi-round-trip auto-fulfilment driver exhausted its round cap + * (`inputRequired.maxRounds`) without the server returning a complete + * result. `data.rounds` carries the cap that was hit and + * `data.lastResult` carries the last `input_required` payload received + * (`{ inputRequests, requestState? }`), so callers can inspect or resume + * the flow manually. + */ + InputRequiredRoundsExceeded = 'INPUT_REQUIRED_ROUNDS_EXCEEDED', /** * The spec method being sent does not exist on the negotiated protocol * version's wire era (e.g. `tasks/get` toward a 2026-07-28 peer, or diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 88b806707f..3257f6df2d 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -108,6 +108,7 @@ export { isCallToolResult, isInitializedNotification, isInitializeRequest, + isInputRequiredResult, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b74b370335..0c9e2a22f5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,9 @@ export * from './shared/authUtils.js'; export * from './shared/clientCapabilityRequirements.js'; export * from './shared/envelope.js'; export * from './shared/inboundClassification.js'; +export * from './shared/inputRequired.js'; +export * from './shared/inputRequiredDriver.js'; +export * from './shared/inputRequiredEngine.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/protocolEras.js'; diff --git a/packages/core/src/shared/inputRequired.ts b/packages/core/src/shared/inputRequired.ts new file mode 100644 index 0000000000..dfd2cac05f --- /dev/null +++ b/packages/core/src/shared/inputRequired.ts @@ -0,0 +1,186 @@ +/** + * Authoring helpers for multi-round-trip requests (protocol revision + * 2026-07-28). + * + * A handler for one of the multi-round-trip methods (`tools/call`, + * `prompts/get`, `resources/read`) requests additional client input by + * returning an {@linkcode InputRequiredResult} instead of a final result. The + * helpers here build that return value and its embedded requests as NEUTRAL + * values; only the 2026-07-28 wire codec maps them to/from the wire (the + * 2025-era codec has no input-required vocabulary — on a 2025-era request the + * server seam fails such a return loudly; a handler that serves both eras + * branches on the served era and uses the push-style APIs toward 2025-era + * requests). + * + * There is no nominal brand: `resultType: 'input_required'` is the + * discriminator, and hand-built result literals are equally legal — the + * server seam re-checks the at-least-one rule for them. + */ +import { isInputRequiredResult } from '../types/guards.js'; +import type { + CreateMessageRequestParams, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + InputRequest, + InputRequests, + InputRequiredResult, + InputResponses +} from '../types/types.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; + +/** The shape accepted by {@linkcode inputRequired}. */ +export interface InputRequiredSpec { + /** Embedded requests the client must fulfil before retrying. */ + inputRequests?: InputRequests; + /** Opaque server state echoed back verbatim by the client on retry. */ + requestState?: string; +} + +interface InputRequiredBuilder { + /** + * Builds the input-required return value for a multi-round-trip handler. + * + * At least one of `inputRequests` or `requestState` must be provided + * (spec: basic/patterns/mrtr, server requirements) — the builder throws a + * `TypeError` otherwise, and the server seam re-checks the same rule for + * hand-built results. + * + * `requestState` is opaque, server-minted state. It round-trips through + * the client and comes back as attacker-controlled input: a server that + * lets it influence authorization, resource access, or business logic + * MUST integrity-protect it (e.g. HMAC or AEAD) and MUST reject state + * that fails verification. The SDK does not do this for you. + */ + (spec: InputRequiredSpec): InputRequiredResult; + + /** Builds an embedded form-mode elicitation request (`elicitation/create`). */ + elicit(params: Omit & { mode?: 'form' }): InputRequest; + + /** + * Builds an embedded URL-mode elicitation request (`elicitation/create`). + * On the 2026-07-28 revision URL elicitation rides the multi-round-trip + * flow — the `-32042` error of earlier revisions never appears on this + * era's wire. The 2025-era `elicitationId` is not part of the 2026-07-28 + * URL-mode shape; correlation across retries is the server's own + * identifier inside `requestState`. + */ + elicitUrl(params: Omit): InputRequest; + + /** Builds an embedded sampling request (`sampling/createMessage`). */ + createMessage(params: CreateMessageRequestParams): InputRequest; + + /** Builds an embedded roots listing request (`roots/list`). */ + listRoots(): InputRequest; +} + +function buildInputRequired(spec: InputRequiredSpec): InputRequiredResult { + const hasInputRequests = spec.inputRequests !== undefined && Object.keys(spec.inputRequests).length > 0; + const hasRequestState = typeof spec.requestState === 'string'; + if (!hasInputRequests && !hasRequestState) { + throw new TypeError( + 'inputRequired() requires at least one of inputRequests (with at least one entry) or requestState ' + + '(spec: every InputRequiredResult MUST include at least one of the two)' + ); + } + return { + resultType: 'input_required', + ...(spec.inputRequests !== undefined && { inputRequests: spec.inputRequests }), + ...(spec.requestState !== undefined && { requestState: spec.requestState }) + }; +} + +/** + * Builder for the input-required return value of multi-round-trip handlers, + * with per-kind constructors for the embedded requests + * (`inputRequired.elicit`, `inputRequired.elicitUrl`, + * `inputRequired.createMessage`, `inputRequired.listRoots`). + * + * @example Write-once tool requesting confirmation + * ```ts + * server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { + * const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + * if (!confirmed) { + * return inputRequired({ + * inputRequests: { + * confirm: inputRequired.elicit({ + * message: `Deploy to ${env}?`, + * requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + * }) + * } + * }); + * } + * return { content: [{ type: 'text', text: `deployed to ${env}` }] }; + * }); + * ``` + */ +export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequired, { + elicit(params: Omit & { mode?: 'form' }): InputRequest { + return { method: 'elicitation/create', params: { ...params, mode: 'form' } }; + }, + elicitUrl(params: Omit): InputRequest { + // The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the + // frozen 2025-11-25 revision); the 2026-07-28 in-band shape does not carry it. + return { method: 'elicitation/create', params: { ...params, mode: 'url' } as ElicitRequestURLParams }; + }, + createMessage(params: CreateMessageRequestParams): InputRequest { + return { method: 'sampling/createMessage', params }; + }, + listRoots(): InputRequest { + return { method: 'roots/list' }; + } +}); + +/** + * Reads the accepted content of a form-mode elicitation response from a + * retried request's `inputResponses` (`ctx.mcpReq.inputResponses`). + * + * Returns the response's `content` for `key` when the entry is an accepted + * elicitation result, and `undefined` otherwise (missing key, declined or + * cancelled elicitation, or a response of another kind). The values arrive + * from the client and are not re-validated here — treat them as untrusted + * input. + */ +export function acceptedContent = Record>( + responses: InputResponses | Record | undefined, + key: string +): T | undefined { + if (responses === undefined || typeof responses !== 'object' || responses === null) return undefined; + const entry = (responses as Record)[key]; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined; + const candidate = entry as Partial & Record; + if (candidate.action !== 'accept') return undefined; + if (candidate.content === undefined || typeof candidate.content !== 'object' || candidate.content === null) return undefined; + return candidate.content as T; +} + +/** + * Wraps a result schema so a request issued through `client.request()` / + * `ctx.mcpReq.send()` with `allowInputRequired: true` is typed as either the + * schema's result or an {@linkcode InputRequiredResult}. + * + * The manual multi-round-trip path: pass `{ allowInputRequired: true }` in the + * request options so an `input_required` response is handed back to the + * caller instead of being auto-fulfilled (or rejected), and wrap the result + * schema with `withInputRequired()` so the returned value is typed and + * validated correctly for both outcomes — `input_required` values pass + * through as-is, complete results validate against the wrapped schema. + */ +export function withInputRequired( + schema: S +): StandardSchemaV1 | InputRequiredResult> { + return { + '~standard': { + version: 1, + vendor: 'modelcontextprotocol', + validate: (value: unknown, options?: StandardSchemaV1.Options) => { + if (isInputRequiredResult(value)) { + return { value }; + } + return schema['~standard'].validate(value, options) as + | StandardSchemaV1.Result | InputRequiredResult> + | Promise | InputRequiredResult>>; + } + } + }; +} diff --git a/packages/core/src/shared/inputRequiredDriver.ts b/packages/core/src/shared/inputRequiredDriver.ts new file mode 100644 index 0000000000..7142f20175 --- /dev/null +++ b/packages/core/src/shared/inputRequiredDriver.ts @@ -0,0 +1,286 @@ +/** + * The multi-round-trip auto-fulfilment driver (protocol revision 2026-07-28). + * + * When a request to one of the multi-round-trip methods comes back as + * `input_required`, the driver fulfils the embedded input requests by + * dispatching them to the client's already-registered handlers (elicitation, + * sampling, roots — one generic engine, no per-feature API), then retries the + * original request with the collected `inputResponses` and a byte-exact echo + * of `requestState`, on a fresh request id, until the server returns a + * complete result or the round cap is exhausted. + * + * The driver is a LAYER OVER THE MANUAL PATH: each retry is issued with the + * same primitive a manual caller uses (`allowInputRequired` semantics — the + * retry hands back the next `input_required` payload instead of recursing), + * so the loop, the cap, and the pacing live in one place and disabling + * auto-fulfilment (`inputRequired.autoFulfill: false`) simply skips this + * module. Timeouts ride the EXISTING knobs: the per-leg `timeout` applies to + * every wire leg unchanged, and `maxTotalTimeout` bounds the whole flow by + * shrinking the budget passed to each leg — no new timer system. + */ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import { isInputRequiredResult } from '../types/guards.js'; +import type { Progress } from '../types/types.js'; + +/** + * Whether the multi-round-trip driver fulfils `input_required` results + * automatically when the consumer has not configured + * `inputRequired.autoFulfill`. The single switch for the default posture. + */ +export const DEFAULT_INPUT_REQUIRED_AUTO_FULFILL = true; + +/** + * Default round cap for the auto-fulfilment driver (both request legs and + * requestState-only legs count). Aligned with the other SDK client engines. + */ +export const DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10; + +/** + * Fixed pacing applied before retrying a requestState-only (load-shedding) + * leg — a leg that carries no embedded input requests, so nothing slows the + * loop down naturally. Counted in the same round cap. + */ +export const REQUEST_STATE_ONLY_LEG_PACING_MS = 250; + +/** + * Multi-round-trip driver options (`inputRequired` on the client options bag). + */ +export interface InputRequiredOptions { + /** + * Fulfil `input_required` results automatically by dispatching the + * embedded requests to the registered handlers and retrying. + * + * Set to `false` for manual mode: an `input_required` response then + * surfaces as a typed error unless the individual call opts in with + * `allowInputRequired: true` (and, for typed results on the explicit + * schema path, `withInputRequired()`). + * + * @default true + */ + autoFulfill?: boolean; + + /** + * Maximum number of rounds (retries) the driver performs for a single + * call before failing with a typed + * {@linkcode SdkErrorCode.InputRequiredRoundsExceeded} error. + * + * @default 10 + */ + maxRounds?: number; +} + +/** The driver configuration with defaults applied. */ +export interface ResolvedInputRequiredDriverConfig { + autoFulfill: boolean; + maxRounds: number; +} + +export function resolveInputRequiredDriverConfig(options: InputRequiredOptions | undefined): ResolvedInputRequiredDriverConfig { + return { + autoFulfill: options?.autoFulfill ?? DEFAULT_INPUT_REQUIRED_AUTO_FULFILL, + maxRounds: options?.maxRounds ?? DEFAULT_INPUT_REQUIRED_MAX_ROUNDS + }; +} + +/** The discriminated `input_required` payload the wire codec hands to the driver. */ +export interface InputRequiredPayload { + inputRequests: Record; + requestState?: string; +} + +/** The slice of per-request options the driver consumes. */ +export interface InputRequiredDriverRequestOptions { + timeout?: number; + maxTotalTimeout?: number; + onprogress?: (progress: Progress) => void; +} + +/** Per-leg options the driver passes back to the funnel for each retry. */ +export interface InputRequiredRetryLegOptions { + timeout?: number; + maxTotalTimeout?: number; +} + +/** The hooks the engine provides to the driver. */ +export interface InputRequiredDriverHooks { + /** + * Dispatches one embedded input request to the locally registered handler + * and resolves with the bare response value. Rejections fail the whole + * call (typed errors: unknown kind, missing handler, handler failure). + * The signal is the per-round abort: when one sibling fails (or the + * caller aborts the originating call) the remaining dispatches are + * cancelled. + */ + dispatchInputRequest(key: string, entry: unknown, signal: AbortSignal): Promise; + + /** + * Re-issues the original request with the given params on a fresh request + * id, using the manual primitive: a complete result resolves validated, + * and a further `input_required` response resolves as the raw + * input-required value (never recursing into another driver run). + */ + retry(params: Record | undefined, legOptions: InputRequiredRetryLegOptions): Promise; +} + +/** Builds the retry params: original params + this round's responses + byte-exact requestState echo. */ +export function buildInputRequiredRetryParams( + originalParams: Record | undefined, + responses: Record | undefined, + requestState: string | undefined +): Record | undefined { + const hasResponses = responses !== undefined && Object.keys(responses).length > 0; + if (!hasResponses && requestState === undefined) { + return originalParams; + } + return { + ...originalParams, + ...(hasResponses && { inputResponses: responses }), + // Byte-exact echo: the opaque string is copied verbatim, never parsed. + // When the result carried no requestState, the retry carries none. + ...(requestState !== undefined && { requestState }) + }; +} + +/** + * Abortable delay: resolves after `ms`, or rejects with the signal's reason + * (wrapped in an `SdkError` when it isn't already one) if the signal aborts + * first. Aborting after resolution is a no-op. + */ +function sleep(ms: number, signal: AbortSignal | undefined): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal.reason))); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = (): void => { + clearTimeout(timer); + reject(signal?.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal?.reason))); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** + * A per-round abort linked to the caller's signal: the embedded sibling + * dispatches share it, so the first failure (or a caller abort) cancels the + * others instead of leaving them running. + */ +function linkedRoundAbort(outer: AbortSignal | undefined): { signal: AbortSignal; abort: (reason: unknown) => void; dispose: () => void } { + const controller = new AbortController(); + const onOuterAbort = (): void => controller.abort(outer?.reason); + outer?.addEventListener('abort', onOuterAbort, { once: true }); + if (outer?.aborted) controller.abort(outer.reason); + return { + signal: controller.signal, + abort: reason => controller.abort(reason), + dispose: () => outer?.removeEventListener('abort', onOuterAbort) + }; +} + +/** + * Runs the auto-fulfilment loop for one originating request. Resolves with + * the final complete result (already validated by the retry leg) or rejects + * with a typed error. + * + * `flowStartedAt` is the timestamp the ORIGINAL request was issued at (not + * when the driver started): `maxTotalTimeout` bounds the whole flow, so the + * first wire leg counts against the budget too. When omitted, accounting + * starts when the driver starts. + */ +export async function runInputRequiredDriver(args: { + config: ResolvedInputRequiredDriverConfig; + method: string; + originalParams: Record | undefined; + firstPayload: InputRequiredPayload; + requestOptions: InputRequiredDriverRequestOptions; + hooks: InputRequiredDriverHooks; + /** The originating call's abort signal — chains through every round and the pacing sleep. */ + signal?: AbortSignal; + flowStartedAt?: number; +}): Promise { + const { config, method, originalParams, requestOptions, hooks, signal } = args; + const startedAt = args.flowStartedAt ?? Date.now(); + let payload = args.firstPayload; + let round = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + round += 1; + if (round > config.maxRounds) { + throw new SdkError( + SdkErrorCode.InputRequiredRoundsExceeded, + `Multi-round-trip request '${method}' still required input after ${config.maxRounds} rounds (inputRequired.maxRounds)`, + { + rounds: config.maxRounds, + lastResult: { + inputRequests: payload.inputRequests, + ...(payload.requestState !== undefined && { requestState: payload.requestState }) + } + } + ); + } + + // Surface the round as synthetic progress: long interactive flows stay + // observable, and consumers composing `resetTimeoutOnProgress`-style + // watchdogs around the call see liveness instead of silence. + requestOptions.onprogress?.({ progress: round, message: `Fulfilling input required by '${method}' (round ${round})` }); + + const entries = Object.entries(payload.inputRequests ?? {}); + let responses: Record | undefined; + if (entries.length > 0) { + // Fulfil concurrently (the embedded requests are independent); a + // single failure fails the call AND aborts the siblings via the + // linked per-round signal so they do not keep running. + const round = linkedRoundAbort(signal); + try { + const fulfilled = await Promise.all( + entries.map(async ([key, entry]) => { + try { + return [key, await hooks.dispatchInputRequest(key, entry, round.signal)] as const; + } catch (error) { + round.abort(error); + throw error; + } + }) + ); + responses = Object.fromEntries(fulfilled); + } finally { + round.dispose(); + } + } else { + // requestState-only (load-shedding) leg: fixed pacing so the loop + // never hot-spins; counted in the same round cap. The sleep + // honors the caller's abort signal. + await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS, signal); + } + + const legOptions: InputRequiredRetryLegOptions = { + ...(requestOptions.timeout !== undefined && { timeout: requestOptions.timeout }) + }; + if (requestOptions.maxTotalTimeout !== undefined) { + const totalElapsed = Date.now() - startedAt; + const remaining = requestOptions.maxTotalTimeout - totalElapsed; + if (remaining <= 0) { + throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { + maxTotalTimeout: requestOptions.maxTotalTimeout, + totalElapsed + }); + } + legOptions.maxTotalTimeout = remaining; + } + + const result = await hooks.retry(buildInputRequiredRetryParams(originalParams, responses, payload.requestState), legOptions); + if (isInputRequiredResult(result)) { + payload = { + inputRequests: result.inputRequests ?? {}, + ...(result.requestState !== undefined && { requestState: result.requestState }) + }; + continue; + } + return result; + } +} diff --git a/packages/core/src/shared/inputRequiredEngine.ts b/packages/core/src/shared/inputRequiredEngine.ts new file mode 100644 index 0000000000..f71c8d471a --- /dev/null +++ b/packages/core/src/shared/inputRequiredEngine.ts @@ -0,0 +1,238 @@ +/** + * The multi-round-trip auto-fulfilment ENGINE (protocol revision 2026-07-28): + * the wiring between the protocol layer's response funnel, the + * already-registered input handlers, and the pure {@link runInputRequiredDriver} + * loop. The engine is what the `Client` plugs into the funnel's + * `_resolveNonCompleteResult` extension point — `Protocol` itself only knows + * the input-required branch exists. + * + * Relocated here so the shared `Protocol` base stays generic: the only + * MRTR-specific code that remains in `protocol.ts` is the irreducible + * input-required branch in the response path, the type surface (the + * `allowInputRequired` request option and the `inputResponses`/`requestState`/ + * `droppedInputResponseKeys` context fields), and the named extension point. + */ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import type { InputRequiredResult, JSONRPCRequest, RequestMeta, Result } from '../types/types.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; +import type { WireCodec } from '../wire/codec.js'; +import type { + InputRequiredDriverHooks, + InputRequiredPayload, + InputRequiredRetryLegOptions, + ResolvedInputRequiredDriverConfig +} from './inputRequiredDriver.js'; +import { runInputRequiredDriver } from './inputRequiredDriver.js'; +import type { BaseContext, NonCompleteResultFlow, RequestOptions } from './protocol.js'; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Splits a retried request's `inputResponses` map into the BARE response + * entries the spec defines and everything else. The spec's embedded responses + * are the bare result objects (an `ElicitResult`, `CreateMessageResult`, or + * `ListRootsResult`); a wrapped `{method, result}` envelope (a shape some + * peers emit) is never accepted as a response — its key is recorded so the + * handler can re-issue the corresponding input request. + */ +export function partitionInputResponses(inputResponses: unknown): { accepted: Record; droppedKeys: string[] } { + const accepted: Record = {}; + const droppedKeys: string[] = []; + if (!isPlainObject(inputResponses)) { + return { accepted, droppedKeys }; + } + for (const [key, entry] of Object.entries(inputResponses)) { + // Bare responses never carry `method` or `result` members — both are + // the signature of the wrapped (JSON-RPC-shaped) form. + if (!isPlainObject(entry) || 'method' in entry || 'result' in entry) { + droppedKeys.push(key); + continue; + } + accepted[key] = entry; + } + return { accepted, droppedKeys }; +} + +/** + * Related send/notify are unavailable inside an embedded input-request + * handler: the request is fulfilled locally by the multi-round-trip driver, + * so there is no live peer request to relate messages to. + */ +function relatedMessagingUnavailable(member: string): never { + throw new SdkError( + SdkErrorCode.SendFailed, + `ctx.mcpReq.${member} is not available while fulfilling an embedded input request: ` + + `the request is fulfilled locally and has no related peer request` + ); +} + +/** + * The synthesized {@linkcode BaseContext} for an embedded input request: the + * id is the `inputRequests` key (correlation only — it is not a JSON-RPC + * message id), the supplied abort signal chains the originating call's signal + * through, and related `send`/`notify` are unavailable because there is no + * live peer request to relate them to. + */ +export function synthesizeInputRequestContext( + key: string, + method: string, + params: Record | undefined, + signal: AbortSignal, + sessionId: string | undefined +): BaseContext { + return { + sessionId, + mcpReq: { + id: key, + method, + _meta: params?.['_meta'] as RequestMeta | undefined, + signal, + send: (() => relatedMessagingUnavailable('send')) as BaseContext['mcpReq']['send'], + notify: () => relatedMessagingUnavailable('notify') + } + }; +} + +/** + * Hooks the engine needs from the consuming role class (the `Client`): how to + * look up a registered handler and how to enrich a base context. + */ +export interface InputRequiredEngineHost { + /** The handler registered for the given method, or `undefined`. */ + getRequestHandler(method: string): ((request: JSONRPCRequest, ctx: unknown) => Promise) | undefined; + /** Builds the role-specific context from a {@linkcode BaseContext}. */ + buildContext(baseCtx: BaseContext): unknown; + /** The transport's session identifier, when there is one. */ + sessionId: string | undefined; +} + +/** + * Dispatches one embedded (de-JSON-RPC'd) input request to the locally + * registered handler for its method and resolves with the bare response. + * + * The handler runs through the same stored handler chain as a wire request + * (including role-specific validation installed by `_wrapHandler`), with a + * synthesized context (see {@link synthesizeInputRequestContext}). + */ +export async function dispatchInputRequest( + host: InputRequiredEngineHost, + codec: WireCodec, + key: string, + entry: unknown, + signal: AbortSignal +): Promise { + if (!isPlainObject(entry) || typeof entry['method'] !== 'string') { + throw new SdkError( + SdkErrorCode.InvalidResult, + `Invalid input request '${key}': each inputRequests entry must be an embedded request object with a method`, + { key } + ); + } + const method = entry['method']; + if (codec.inputRequestSchema(method) === undefined) { + throw new SdkError( + SdkErrorCode.InvalidResult, + `Invalid input request '${key}': '${method}' is not an embedded request the ${codec.era} revision defines ` + + `(expected elicitation/create, sampling/createMessage, or roots/list)`, + { key, method } + ); + } + const handler = host.getRequestHandler(method); + if (handler === undefined) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Cannot fulfil input request '${key}': no handler is registered for '${method}' on this client. ` + + `Declare the corresponding capability and register a handler, or handle input_required results manually.`, + { key, method } + ); + } + + const params = isPlainObject(entry['params']) ? (entry['params'] as Record) : undefined; + const synthesizedRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: key, + method, + ...(params !== undefined && { params }) + }; + const ctx = host.buildContext(synthesizeInputRequestContext(key, method, params, signal, host.sessionId)); + return await handler(synthesizedRequest, ctx); +} + +/** + * Builds the per-retry-leg {@linkcode RequestOptions} from the originating + * call's options. + * + * Only the fields that are correct to apply to every leg carry over (a + * deliberate whitelist): the per-leg `timeout`, the (shrinking) total budget + * `maxTotalTimeout`, the caller's `onprogress`/`resetTimeoutOnProgress`, and + * the caller's abort `signal`. Everything else — in particular + * `relatedRequestId`, `resumptionToken`, and `onresumptiontoken` — is scoped + * to the originating wire leg and is NOT inherited by retries. + */ +export function buildRetryLegRequestOptions(options: RequestOptions | undefined, legOptions: InputRequiredRetryLegOptions): RequestOptions { + return { + ...(options?.signal !== undefined && { signal: options.signal }), + ...(options?.onprogress !== undefined && { onprogress: options.onprogress }), + ...(options?.resetTimeoutOnProgress !== undefined && { resetTimeoutOnProgress: options.resetTimeoutOnProgress }), + ...(legOptions.timeout !== undefined && { timeout: legOptions.timeout }), + ...(legOptions.maxTotalTimeout !== undefined && { maxTotalTimeout: legOptions.maxTotalTimeout }), + // The driver re-enters the funnel with the manual primitive: a further + // input_required answer is handed back to the loop instead of + // recursing into another driver run (the round cap is global to the + // flow). + allowInputRequired: true + }; +} + +/** + * Runs the auto-fulfilment flow for one originating request whose response + * came back as `input_required`: builds the driver hooks (embedded-request + * dispatch + retry through the funnel) and hands them to + * {@link runInputRequiredDriver}. Resolves with the final complete result + * (already validated by the retry leg) or rejects with a typed error. + */ +export function runInputRequiredFlow( + host: InputRequiredEngineHost, + config: ResolvedInputRequiredDriverConfig, + decoded: { inputRequests: Record; requestState?: string }, + flow: NonCompleteResultFlow +): Promise { + const { codec, request, options, flowStartedAt } = flow; + const firstPayload: InputRequiredPayload = { + inputRequests: decoded.inputRequests, + ...(decoded.requestState !== undefined && { requestState: decoded.requestState }) + }; + const hooks: InputRequiredDriverHooks = { + dispatchInputRequest: (key, entry, signal) => dispatchInputRequest(host, codec, key, entry, signal), + retry: (params, legOptions) => flow.retry(params, buildRetryLegRequestOptions(options, legOptions)) + }; + return runInputRequiredDriver({ + config, + method: request.method, + originalParams: request.params, + firstPayload, + flowStartedAt, + signal: options?.signal, + requestOptions: { + ...(options?.timeout !== undefined && { timeout: options.timeout }), + ...(options?.maxTotalTimeout !== undefined && { maxTotalTimeout: options.maxTotalTimeout }), + ...(options?.onprogress !== undefined && { onprogress: options.onprogress }) + }, + hooks + }); +} + +/** + * Builds the manual-mode {@linkcode InputRequiredResult} value from the + * codec's decoded payload — what an `allowInputRequired: true` caller + * receives instead of the auto-fulfilled complete result. + */ +export function manualInputRequiredValue(decoded: { inputRequests: Record; requestState?: string }): InputRequiredResult { + return { + resultType: 'input_required', + inputRequests: decoded.inputRequests as InputRequiredResult['inputRequests'], + ...(decoded.requestState !== undefined && { requestState: decoded.requestState }) + }; +} diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 8feffae174..d60bfc423a 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -9,6 +9,7 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + HandlerResultTypeMap, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, @@ -49,6 +50,7 @@ import { isStandardSchema, validateStandardSchema } from '../util/standardSchema import { bootstrapOutboundCodec } from '../wire/bootstrap.js'; import type { LiftedWireMaterial, WireCodec } from '../wire/codec.js'; import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod } from '../wire/codec.js'; +import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** @@ -124,10 +126,46 @@ export type RequestOptions = { * Maximum total time (in milliseconds) to wait for a response. * If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised, regardless of progress notifications. * If not specified, there is no maximum total timeout. + * + * For multi-round-trip requests fulfilled by the auto-fulfilment driver + * (protocol revision 2026-07-28), the budget bounds the WHOLE flow: every + * retry leg is given only the time remaining. */ maxTotalTimeout?: number; + + /** + * Manual multi-round-trip mode for this call (protocol revision + * 2026-07-28): when the response is an `input_required` result, hand it + * back to the caller instead of auto-fulfilling it (or raising a typed + * error). The resolved value is the neutral input-required shape + * (`resultType: 'input_required'`, `inputRequests?`, `requestState?`); + * wrap the result schema with `withInputRequired()` on the explicit + * schema path to type both outcomes. The caller is then responsible for + * gathering the requested input and retrying the original request with + * `inputResponses` / `requestState` params and a fresh request. + * + * Default: `false`. + */ + allowInputRequired?: boolean; } & TransportSendOptions; +/** + * Flow context handed to {@linkcode Protocol._resolveNonCompleteResult}: the + * originating request, its options, the wire codec that decoded the response, + * the timestamp the originating leg was issued at (for whole-flow timeout + * accounting), and a `retry` closure that re-enters the request funnel with + * fresh params on a fresh request id. + */ +export interface NonCompleteResultFlow { + codec: WireCodec; + request: Request; + resultSchema: T; + options: RequestOptions | undefined; + flowStartedAt: number; + /** Re-issue the originating request with the given params and per-leg options. */ + retry(params: Record | undefined, legOptions: RequestOptions): Promise; +} + /** * Options that can be given per notification. */ @@ -255,14 +293,36 @@ export type BaseContext = { /** * Multi-round-trip input responses carried by a retried request * (protocol revision 2026-07-28), lifted out of the params the - * handler sees. Driver material — present verbatim when sent. + * handler sees. Entries are the BARE response objects keyed by the + * identifiers the server assigned in `inputRequests`; entries that do + * not look like bare responses (e.g. a `{method, result}` wrapper) + * are dropped and their keys recorded in `droppedInputResponseKeys`. + * + * The values arrive from the client and are NOT validated by the SDK + * — treat them as untrusted input. */ inputResponses?: Record; + /** + * Keys of `inputResponses` entries the SDK dropped because they were + * not bare response objects (for example the wrapped `{method, + * result}` shape some peers emit). Surfaced so a handler can re-issue + * the corresponding input request rather than hard-fail. + */ + droppedInputResponseKeys?: string[]; + /** * Multi-round-trip request state echoed by a retried request * (protocol revision 2026-07-28), lifted out of the params the * handler sees. Driver material — present verbatim when sent. + * + * SECURITY: `requestState` round-trips through the client and MUST be + * treated as attacker-controlled input. The SDK applies no integrity + * protection: if this value influences authorization, resource + * access, or business logic, the server MUST integrity-protect it + * (e.g. HMAC or AEAD) when minting it and MUST verify it here, + * rejecting state that fails verification (spec: + * basic/patterns/mrtr, server requirements 4–5). */ requestState?: string; @@ -509,6 +569,45 @@ export abstract class Protocol { return undefined; } + /** + * Extension point for non-`complete` decoded results in the response + * funnel: a result the wire codec discriminated into a kind other than + * `'complete'` or `'invalid'` is handed here for the role class to + * resolve. The base default surfaces it as a typed + * {@linkcode SdkErrorCode.UnsupportedResultType} error (no retry). + * + * Intended consumers (named so the seam stays accountable): + * - the `Client`'s multi-round-trip auto-fulfilment engine, which fulfils + * `'input_required'` results through the registered + * elicitation/sampling/roots handlers and retries via `flow.retry`; + * - a future client-side terminal-result handler for + * `subscriptions/listen`, when the spec defines one. + * + * `Server` instances never receive `input_required` responses on their + * outbound legs and leave the base behavior in place. + */ + protected _resolveNonCompleteResult( + decoded: ReturnType & { kind: 'input_required' }, + flow: NonCompleteResultFlow + ): Promise { + return Promise.reject( + new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type '${decoded.kind}' for ${flow.request.method}`, { + resultType: decoded.kind, + method: flow.request.method + }) + ); + } + + /** + * Protected accessor for a registered request handler. Used by role + * classes that dispatch synthesized requests through the same stored + * handler chain (e.g. the `Client` fulfilling an embedded multi-round-trip + * input request). + */ + protected _getRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { + return this._requestHandlers.get(method); + } + private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { return; @@ -817,6 +916,13 @@ export abstract class Protocol { const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); + // Multi-round-trip retry material: only BARE response objects are + // surfaced to the handler; entries that look like a wrapped + // `{method, result}` shape (or are not objects at all) are dropped + // and their keys recorded so the handler can re-issue the input + // request instead of hard-failing (D-059 posture). + const partitionedInputResponses = lifted.inputResponses === undefined ? undefined : partitionInputResponses(lifted.inputResponses); + const baseCtx: BaseContext = { sessionId: capturedTransport?.sessionId, mcpReq: { @@ -824,7 +930,11 @@ export abstract class Protocol { method: request.method, _meta: request.params?._meta, ...(lifted.envelope !== undefined && { envelope: lifted.envelope }), - ...(lifted.inputResponses !== undefined && { inputResponses: lifted.inputResponses }), + ...(partitionedInputResponses !== undefined && { inputResponses: partitionedInputResponses.accepted }), + ...(partitionedInputResponses !== undefined && + partitionedInputResponses.droppedKeys.length > 0 && { + droppedInputResponseKeys: partitionedInputResponses.droppedKeys + }), ...(lifted.requestState !== undefined && { requestState: lifted.requestState }), signal: abortController.signal, // BaseContext.mcpReq.send is declared with two overloads (spec-method-keyed and explicit-schema). Arrow @@ -1103,6 +1213,10 @@ export abstract class Protocol { options?: RequestOptions ): Promise> { const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; + // Flow start for non-complete result resolution: `maxTotalTimeout` + // bounds the WHOLE flow, so the budget is measured from the original + // request, not from when an extension takes over after the first leg. + const flowStartedAt = Date.now(); let onAbort: (() => void) | undefined; let cleanupMessageId: number | undefined; @@ -1209,15 +1323,30 @@ export abstract class Protocol { return reject(decoded.error); } if (decoded.kind === 'input_required') { - // Driver seam: the multi-round-trip driver (M4.1) - // consumes this payload; until it lands, surface the - // discriminated kind as a typed local error, no retry. - return reject( - new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type 'input_required' for ${request.method}`, { - resultType: 'input_required', - method: request.method - }) - ); + // Manual mode (the primitive any driver layers over): + // hand the input-required value back to the caller. + if (options?.allowInputRequired === true) { + return resolve(manualInputRequiredValue(decoded) as StandardSchemaV1.InferOutput); + } + // Non-complete result extension point: the role class may + // resolve the flow itself (the Client wires the + // multi-round-trip auto-fulfilment engine here). The base + // default is the typed UnsupportedResultType error. + const flow: NonCompleteResultFlow = { + codec, + request, + resultSchema, + options, + flowStartedAt, + retry: (params, legOptions) => + this._requestWithSchemaViaCodec( + codec, + params === undefined ? { method: request.method } : { method: request.method, params }, + resultSchema, + legOptions + ) + }; + return resolve(this._resolveNonCompleteResult(decoded, flow) as Promise>); } const result = decoded.result; @@ -1348,7 +1477,7 @@ export abstract class Protocol { */ setRequestHandler( method: M, - handler: (request: RequestTypeMap[M], ctx: ContextT) => ResultTypeMap[M] | Promise + handler: (request: RequestTypeMap[M], ctx: ContextT) => HandlerResultTypeMap[M] | Promise ): void; setRequestHandler

( method: string, @@ -1373,9 +1502,14 @@ export abstract class Protocol { // Dispatch-time schema resolution: the request is parsed with the // schema of the era serving this connection (the instance era at // dispatch time), never with a schema captured at registration - // time. + // time. On the 2026-07-28 era the demoted server→client methods + // (elicitation/sampling/roots) are not wire request methods — + // they reach a handler only as embedded input requests dispatched + // by the multi-round-trip driver, and parse with the era's + // in-band schema instead. stored = (request, ctx) => { - const schema = this._negotiatedWireCodec().requestSchema(method); + const dispatchCodec = this._negotiatedWireCodec(); + const schema = dispatchCodec.requestSchema(method) ?? dispatchCodec.inputRequestSchema(method); if (!schema) { // Unreachable: the dispatch era gate rejects era-mismatched // spec methods with −32601 before any handler runs. diff --git a/packages/core/src/types/guards.ts b/packages/core/src/types/guards.ts index 8091b962c1..0a5f4b7cd4 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -17,6 +17,7 @@ import type { CompleteRequestResourceTemplate, InitializedNotification, InitializeRequest, + InputRequiredResult, JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, @@ -87,6 +88,24 @@ export const isCallToolResult = (value: unknown): value is CallToolResult => { return CallToolResultSchema.safeParse(value).success; }; +/** + * Checks whether a value is an input-required result (protocol revision + * 2026-07-28): the multi-round-trip return shape discriminated by + * `resultType: 'input_required'`. + * + * This is a discriminator check, not a full validator — the at-least-one rule + * (`inputRequests` or `requestState`) is enforced by the `inputRequired()` + * builder and re-checked by the server seam for hand-built values. + * + * @param value - The value to check. + * @returns True if the value carries the `input_required` discriminator. + */ +export const isInputRequiredResult = (value: unknown): value is InputRequiredResult => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (value as { resultType?: unknown }).resultType === 'input_required'; + /** * Checks if a value is a valid {@linkcode TaskAugmentedRequestParams}. * @param value - The value to check. diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 55b2bf7481..94b6408fd3 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -436,6 +436,81 @@ export type ListRootsRequest = Infer; export type ListRootsResult = StripWireOnly>; export type RootsListChangedNotification = Infer; +/* Multi round-trip requests (protocol revision 2026-07-28) + * + * On the 2026-07-28 revision the server obtains client input (elicitation, + * sampling, roots) in-band: instead of sending a server→client JSON-RPC + * request, a handler for one of the multi-round-trip methods (`tools/call`, + * `prompts/get`, `resources/read`) returns an input-required result carrying + * de-JSON-RPC'd embedded requests; the client fulfils them and retries the + * original request with the responses. These are the NEUTRAL shapes of that + * surface — handlers author them and the 2026-07-28 wire codec alone maps + * them to/from the wire. + */ + +/** + * A single embedded (de-JSON-RPC'd) input request inside an + * {@linkcode InputRequiredResult}: an elicitation, sampling, or roots request + * object carried in-band rather than sent as a server→client JSON-RPC request. + */ +export type InputRequest = CreateMessageRequest | ListRootsRequest | ElicitRequest; + +/** + * A single embedded (de-JSON-RPC'd) input response inside a retried request's + * `inputResponses`: the bare result object for the corresponding + * {@linkcode InputRequest} (never wrapped in a `{method, result}` envelope). + */ +export type InputResponse = CreateMessageResult | ListRootsResult | ElicitResult; + +/** + * A map of embedded input requests, keyed by server-assigned identifiers that + * are unique within the scope of the request. + */ +export interface InputRequests { + [key: string]: InputRequest; +} + +/** + * A map of embedded input responses. Keys correspond to the keys of the + * {@linkcode InputRequests} map the server sent; values are the client's bare + * result for each request. + */ +export interface InputResponses { + [key: string]: InputResponse; +} + +/** + * The input-required result a handler for a multi-round-trip method + * (`tools/call`, `prompts/get`, `resources/read`) returns to request more + * input from the client (protocol revision 2026-07-28). Build it with the + * `inputRequired()` builder; hand-built literals are equally legal — + * `resultType: 'input_required'` is the discriminator, and the SDK re-checks + * the at-least-one rule at the seam. + * + * This is the one place the wire discriminator `resultType` appears on the + * neutral surface: the handler authors it, the 2026-07-28 codec passes it + * through to the wire, and consumers receiving results never see it (complete + * results are lifted). + * + * At least one of `inputRequests` or `requestState` must be present. + * + * `requestState` is an opaque, server-minted string echoed back verbatim by + * the client on retry. It travels through the client and MUST be treated by + * the server as attacker-controlled input on re-entry: if it influences + * authorization, resource access, or business logic, the server MUST protect + * its integrity (e.g. HMAC or AEAD) and MUST reject state that fails + * verification (spec: basic/patterns/mrtr §Server Requirements). The SDK + * surfaces it raw at `ctx.mcpReq.requestState` and applies no integrity + * protection of its own. + */ +export interface InputRequiredResult extends Result { + resultType: 'input_required'; + /** Embedded requests the client must fulfil before retrying. */ + inputRequests?: InputRequests; + /** Opaque server state the client echoes back verbatim on retry. */ + requestState?: string; +} + /* Client messages */ export type ClientRequest = Infer; export type ClientNotification = Infer; @@ -483,6 +558,23 @@ export type ResultTypeMap = { 'roots/list': ListRootsResult; }; +/** + * The handler-return counterpart of {@linkcode ResultTypeMap}: what a + * registered request handler may RETURN for each method. Identical to + * `ResultTypeMap` except that the multi-round-trip methods (`tools/call`, + * `prompts/get`, `resources/read`) additionally accept an + * {@linkcode InputRequiredResult} (protocol revision 2026-07-28). + * + * `ResultTypeMap` itself — what a *requester* receives — is deliberately NOT + * widened: `client.callTool()` returns a plain {@linkcode CallToolResult} on + * both protocol eras. + */ +export type HandlerResultTypeMap = { + [M in keyof ResultTypeMap]: M extends 'tools/call' | 'prompts/get' | 'resources/read' + ? ResultTypeMap[M] | InputRequiredResult + : ResultTypeMap[M]; +}; + /** * Information about a validated access token, provided to request handlers. */ diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index d98d8e23f1..878922db8b 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -131,6 +131,20 @@ export interface WireCodec { notificationSchema(method: M): z.ZodType | undefined; notificationSchema(method: string): z.ZodType | undefined; + /** + * In-band (de-JSON-RPC'd) input-request vocabulary of this era — the + * embedded requests a multi-round-trip `input_required` result may carry + * and the bare responses that answer them. `undefined` means the method + * is not in-band vocabulary on this era (the 2025-era codec has none: + * elicitation/sampling/roots are wire request methods there). These do + * NOT grant registry membership — a peer sending one of these as a wire + * request on an era that demoted it still gets −32601 by absence. + */ + inputRequestSchema(method: M): z.ZodType | undefined; + inputRequestSchema(method: string): z.ZodType | undefined; + inputResponseSchema(method: M): z.ZodType | undefined; + inputResponseSchema(method: string): z.ZodType | undefined; + /** * Step 1 of result decoding: RAW `resultType` handling BEFORE any schema * validation (V-1's structural home). Era postures (Q1-SD3): diff --git a/packages/core/src/wire/rev2025-11-25/codec.ts b/packages/core/src/wire/rev2025-11-25/codec.ts index 458379d9cd..5ca85ccd30 100644 --- a/packages/core/src/wire/rev2025-11-25/codec.ts +++ b/packages/core/src/wire/rev2025-11-25/codec.ts @@ -44,6 +44,15 @@ export const rev2025Codec: WireCodec = { resultSchema: getResultSchema, notificationSchema: getNotificationSchema, + // No in-band input-request vocabulary on this era: elicitation, sampling + // and roots are real wire request methods here (see the registry). + inputRequestSchema: (): undefined => { + return; + }, + inputResponseSchema: (): undefined => { + return; + }, + decodeResult(_method: string, raw: unknown): DecodedResult { // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is // dropped before validation, whatever its value. There is no diff --git a/packages/core/src/wire/rev2026-07-28/codec.ts b/packages/core/src/wire/rev2026-07-28/codec.ts index 4410a0a05b..2e8680e211 100644 --- a/packages/core/src/wire/rev2026-07-28/codec.ts +++ b/packages/core/src/wire/rev2026-07-28/codec.ts @@ -31,6 +31,7 @@ import { SdkError, SdkErrorCode } from '../../errors/sdkErrors.js'; import type { Result } from '../../types/types.js'; import type { DecodedResult, LiftedWireMaterial, WireCodec } from '../codec.js'; import { fillCacheFields, stampResultType } from './encodeContract.js'; +import { getInputRequestSchema2026, getInputResponseSchema2026 } from './inputRequired.js'; import { getNotificationSchema2026, getRequestSchema2026, @@ -99,6 +100,12 @@ export const rev2026Codec: WireCodec = { resultSchema: getResultSchema2026, notificationSchema: getNotificationSchema2026, + // In-band multi-round-trip vocabulary: the demoted elicitation/sampling/ + // roots shapes carried inside `input_required` results (NOT wire request + // methods on this era — registry membership is deliberately not granted). + inputRequestSchema: getInputRequestSchema2026, + inputResponseSchema: getInputResponseSchema2026, + decodeResult(method: string, raw: unknown): DecodedResult { if (!isPlainObject(raw)) { return { @@ -132,11 +139,28 @@ export const rev2026Codec: WireCodec = { } if (rawResultType === 'input_required') { // The driver seam (#13 consumes this payload). - const inputRequests = raw['inputRequests']; + const rawInputRequests = raw['inputRequests']; + const inputRequests = isPlainObject(rawInputRequests) ? rawInputRequests : {}; + const requestState = raw['requestState']; + if (Object.keys(inputRequests).length === 0 && typeof requestState !== 'string') { + // At-least-one rule, client side: with neither inputRequests + // nor requestState there is nothing to fulfil and nothing to + // echo — retrying would only resend the original params until + // the round cap is exhausted, so fail fast instead. + return { + kind: 'invalid', + error: new SdkError( + SdkErrorCode.InvalidResult, + `Invalid result for ${method}: input_required carries neither inputRequests nor requestState ` + + `(every input_required result must include at least one of the two)`, + { method, violation: 'input-required-missing-both' } + ) + }; + } return { kind: 'input_required', - inputRequests: isPlainObject(inputRequests) ? inputRequests : {}, - ...(typeof raw['requestState'] === 'string' && { requestState: raw['requestState'] }) + inputRequests, + ...(typeof requestState === 'string' && { requestState }) }; } if (rawResultType !== 'complete') { diff --git a/packages/core/src/wire/rev2026-07-28/inputRequired.ts b/packages/core/src/wire/rev2026-07-28/inputRequired.ts new file mode 100644 index 0000000000..365a178d73 --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/inputRequired.ts @@ -0,0 +1,83 @@ +/** + * In-band input-request vocabulary of the 2026-07-28 revision (SEP-2322 + * multi round-trip requests), dispatch view. + * + * The three former server→client wire requests (`elicitation/create`, + * `sampling/createMessage`, `roots/list`) are NOT wire request methods on + * this revision — they are demoted to de-JSON-RPC'd payloads embedded in an + * `input_required` result. The multi-round-trip driver dispatches those + * embedded payloads to the client's registered handlers through the normal + * handler machinery, and these are the schemas that dispatch parses them + * with: lenient where the anchor's wire-true artifacts are strict (an + * embedded request never carries the per-request `_meta` envelope), exact + * where the vocabulary forks (the sampling shapes compose the forked + * SamplingMessage/Tool payloads). + * + * Registry membership is intentionally NOT granted here — these methods stay + * absent from the 2026-era request registry (a peer sending one as a wire + * request still gets −32601 by absence). Only the codec's + * `inputRequestSchema`/`inputResponseSchema` accessors expose them. + */ +import * as z from 'zod/v4'; + +import type { RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; +import { + CreateMessageRequestParamsSchema, + CreateMessageResultSchema, + ElicitRequestParamsSchema, + ElicitResultSchema, + ListRootsResultSchema +} from './schemas.js'; + +/** The embedded input-request methods of the 2026-07-28 revision. */ +export const INPUT_REQUEST_METHODS_2026 = ['elicitation/create', 'sampling/createMessage', 'roots/list'] as const; + +export type InputRequestMethod2026 = (typeof INPUT_REQUEST_METHODS_2026)[number]; + +/** Dispatch-time (lenient) embedded request schemas, keyed by method. */ +const inputRequestSchemas2026: Record = { + 'elicitation/create': z.object({ + method: z.literal('elicitation/create'), + params: ElicitRequestParamsSchema + }), + 'sampling/createMessage': z.object({ + method: z.literal('sampling/createMessage'), + params: CreateMessageRequestParamsSchema + }), + 'roots/list': z.object({ + method: z.literal('roots/list'), + params: z.looseObject({}).optional() + }) +}; + +/** Embedded (bare) response schemas, keyed by the request method they answer. */ +const inputResponseSchemas2026: Record = { + 'elicitation/create': ElicitResultSchema, + 'sampling/createMessage': CreateMessageResultSchema, + 'roots/list': ListRootsResultSchema +}; + +export function isInputRequestMethod2026(method: string): method is InputRequestMethod2026 { + return (INPUT_REQUEST_METHODS_2026 as readonly string[]).includes(method); +} + +/** + * Gets the dispatch (lenient) schema for an embedded input request, or + * `undefined` for methods that are not in-band vocabulary on this era. + * The typed overload mirrors `WireCodec.inputRequestSchema`. + */ +export function getInputRequestSchema2026(method: M): z.ZodType | undefined; +export function getInputRequestSchema2026(method: string): z.ZodType | undefined; +export function getInputRequestSchema2026(method: string): z.ZodType | undefined { + return isInputRequestMethod2026(method) ? inputRequestSchemas2026[method] : undefined; +} + +/** + * Gets the bare embedded-response schema answering an embedded input request, + * or `undefined` for methods that are not in-band vocabulary on this era. + */ +export function getInputResponseSchema2026(method: M): z.ZodType | undefined; +export function getInputResponseSchema2026(method: string): z.ZodType | undefined; +export function getInputResponseSchema2026(method: string): z.ZodType | undefined { + return isInputRequestMethod2026(method) ? inputResponseSchemas2026[method] : undefined; +} diff --git a/packages/core/src/wire/rev2026-07-28/schemas.ts b/packages/core/src/wire/rev2026-07-28/schemas.ts index d6393d9f55..e7e68c8e1c 100644 --- a/packages/core/src/wire/rev2026-07-28/schemas.ts +++ b/packages/core/src/wire/rev2026-07-28/schemas.ts @@ -28,11 +28,14 @@ import { ClientCapabilitiesSchema, ContentBlockSchema, CursorSchema, + ElicitRequestFormParamsSchema, IconsSchema, ImageContentSchema, ImplementationSchema, + JSONObjectSchema, LoggingLevelSchema, LoggingMessageNotificationSchema, + ModelPreferencesSchema, ProgressNotificationSchema, ProgressTokenSchema, PromptListChangedNotificationSchema, @@ -47,10 +50,12 @@ import { ResourceTemplateSchema, ResourceUpdatedNotificationSchema, RoleSchema, + RootSchema, ServerCapabilitiesSchema, TextContentSchema, TextResourceContentsSchema, ToolAnnotationsSchema, + ToolChoiceSchema, ToolListChangedNotificationSchema, ToolUseContentSchema } from '../../types/schemas.js'; @@ -277,6 +282,125 @@ export const DiscoverResultSchema = wireResult({ instructions: z.string().optional() }); +/* ------------------------------------------------------------------------ * + * Multi round-trip requests (SEP-2322). The in-band vocabulary of this + * revision: server→client interactions are carried as de-JSON-RPC'd embedded + * requests inside an `input_required` result, fulfilled by the client, and + * echoed back as embedded responses on the retry. The shapes below are + * anchor-exact wire artifacts (corpus + parity); the lenient dispatch-time + * schemas the multi-round-trip driver parses embedded requests with live in + * `inputRequired.ts`. + * + * The sampling shapes fork here (they compose the forked SamplingMessage / + * Tool payloads); the URL-mode elicitation params fork here (the draft + * removed `elicitationId`; the shared schema keeps it because it is required + * on the frozen 2025-11-25 revision); form-mode elicitation params are + * revision-identical and are composed by reference from the shared schema. + * ------------------------------------------------------------------------ */ + +/** 2026-era CreateMessageRequestParams (anchor-exact: forked SamplingMessage/Tool, no task augmentation). */ +export const CreateMessageRequestParamsSchema = z.object({ + messages: z.array(SamplingMessageSchema), + modelPreferences: ModelPreferencesSchema.optional(), + systemPrompt: z.string().optional(), + includeContext: z.enum(['none', 'thisServer', 'allServers']).optional(), + temperature: z.number().optional(), + maxTokens: z.number().int(), + stopSequences: z.array(z.string()).optional(), + metadata: JSONObjectSchema.optional(), + tools: z.array(ToolSchema).optional(), + toolChoice: ToolChoiceSchema.optional() +}); + +/** 2026-era embedded sampling request (de-JSON-RPC'd). */ +export const CreateMessageRequestSchema = z.object({ + method: z.literal('sampling/createMessage'), + params: CreateMessageRequestParamsSchema +}); + +/** + * 2026-era embedded roots listing request (de-JSON-RPC'd). Embedded input + * requests do NOT carry the per-request `_meta` envelope on this revision — + * the anchor declares a bare optional `_meta` on `params`. + */ +export const ListRootsRequestSchema = z.object({ + method: z.literal('roots/list'), + params: z.object({ _meta: z.record(z.string(), z.unknown()).optional() }).optional() +}); + +/** 2026-era embedded sampling response (anchor-exact: extends the forked SamplingMessage). */ +export const CreateMessageResultSchema = z.object({ + ...SamplingMessageSchema.shape, + model: z.string(), + stopReason: z.string().optional() +}); + +/** 2026-era embedded roots listing response (anchor-exact: bare `roots` array). */ +export const ListRootsResultSchema = z.object({ + roots: z.array(RootSchema) +}); + +/** 2026-era embedded elicitation response (anchor-exact: bare result, restricted content value types). */ +export const ElicitResultSchema = z.object({ + action: z.enum(['accept', 'decline', 'cancel']), + content: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional() +}); + +/** + * 2026-era URL-mode elicitation params (anchor-exact fork): the draft removed + * `elicitationId` (and the `notifications/elicitation/complete` channel it + * keyed) — the shared schema keeps the field because it is required on the + * frozen 2025-11-25 revision. + */ +export const ElicitRequestURLParamsSchema = z.object({ + mode: z.literal('url'), + message: z.string(), + url: z.string().url() +}); + +/** 2026-era elicitation params (form mode is revision-identical; URL mode is the fork above). */ +export const ElicitRequestParamsSchema = z.union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); + +/** 2026-era embedded elicitation request (de-JSON-RPC'd; see the URL-mode fork above). */ +export const ElicitRequestSchema = z.object({ + method: z.literal('elicitation/create'), + params: ElicitRequestParamsSchema +}); + +/** A single embedded input request (one of the three demoted server→client requests). */ +export const InputRequestSchema = z.union([CreateMessageRequestSchema, ListRootsRequestSchema, ElicitRequestSchema]); + +/** A single embedded input response — the BARE result union (never a `{method, result}` wrapper). */ +export const InputResponseSchema = z.union([CreateMessageResultSchema, ListRootsResultSchema, ElicitResultSchema]); + +/** Map of embedded input requests, keyed by server-assigned identifiers. */ +export const InputRequestsSchema = z.record(z.string(), InputRequestSchema); + +/** Map of embedded input responses, keyed by the corresponding request identifiers. */ +export const InputResponsesSchema = z.record(z.string(), InputResponseSchema); + +/** + * The wire InputRequiredResult: `resultType: 'input_required'` plus at least + * one of `inputRequests` / `requestState` (the at-least-one rule is enforced + * at the server seam, not by this parse shape). + */ +export const InputRequiredResultSchema = wireResult({ + inputRequests: InputRequestsSchema.optional(), + requestState: z.string().optional() +}); + +/** The retry-channel members carried by client-initiated requests on this revision. */ +const retryParamsShape = { + inputResponses: InputResponsesSchema.optional(), + requestState: z.string().optional() +}; + +/** Anchor InputResponseRequestParams: the retry channel on top of the required request `_meta` envelope. */ +export const InputResponseRequestParamsSchema = z.object({ + _meta: RequestMetaEnvelopeSchema, + ...retryParamsShape +}); + /* ------------------------------------------------------------------------ * * Request side. Two views per method: * - WIRE-TRUE (`RequestSchema`): params `_meta` carries the REQUIRED @@ -310,7 +434,10 @@ function dispatchRequest(meth const callToolParamsShape = { name: z.string(), - arguments: z.record(z.string(), z.unknown()).optional() + arguments: z.record(z.string(), z.unknown()).optional(), + // Multi-round-trip retry channel (the wire-true view models it; dispatch + // never sees it — the protocol layer lifts it before any handler runs). + ...retryParamsShape }; const paginatedParamsShape = { cursor: CursorSchema.optional() }; @@ -319,11 +446,12 @@ export const ListToolsRequestSchema = wireRequest('tools/list', paginatedParamsS export const ListPromptsRequestSchema = wireRequest('prompts/list', paginatedParamsShape); export const GetPromptRequestSchema = wireRequest('prompts/get', { name: z.string(), - arguments: z.record(z.string(), z.string()).optional() + arguments: z.record(z.string(), z.string()).optional(), + ...retryParamsShape }); export const ListResourcesRequestSchema = wireRequest('resources/list', paginatedParamsShape); export const ListResourceTemplatesRequestSchema = wireRequest('resources/templates/list', paginatedParamsShape); -export const ReadResourceRequestSchema = wireRequest('resources/read', { uri: z.string() }); +export const ReadResourceRequestSchema = wireRequest('resources/read', { uri: z.string(), ...retryParamsShape }); const completeParamsShape = { ref: z.union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), argument: z.object({ name: z.string(), value: z.string() }), @@ -517,13 +645,15 @@ const wireResultResponse = (result: T) => .strict(); export const JSONRPCResultResponseSchema = wireResultResponse(ResultSchema); -export const CallToolResultResponseSchema = wireResultResponse(CallToolResultSchema); +// The multi-round-trip methods may answer with either their final result or an +// InputRequiredResult (anchor: `result: CallToolResult | InputRequiredResult`). +export const CallToolResultResponseSchema = wireResultResponse(z.union([CallToolResultSchema, InputRequiredResultSchema])); export const ListToolsResultResponseSchema = wireResultResponse(ListToolsResultSchema); export const ListPromptsResultResponseSchema = wireResultResponse(ListPromptsResultSchema); -export const GetPromptResultResponseSchema = wireResultResponse(GetPromptResultSchema); +export const GetPromptResultResponseSchema = wireResultResponse(z.union([GetPromptResultSchema, InputRequiredResultSchema])); export const ListResourcesResultResponseSchema = wireResultResponse(ListResourcesResultSchema); export const ListResourceTemplatesResultResponseSchema = wireResultResponse(ListResourceTemplatesResultSchema); -export const ReadResourceResultResponseSchema = wireResultResponse(ReadResourceResultSchema); +export const ReadResourceResultResponseSchema = wireResultResponse(z.union([ReadResourceResultSchema, InputRequiredResultSchema])); export const CompleteResultResponseSchema = wireResultResponse(CompleteResultSchema); export const DiscoverResultResponseSchema = wireResultResponse(DiscoverResultSchema); diff --git a/packages/core/test/corpus/specCorpus.test.ts b/packages/core/test/corpus/specCorpus.test.ts index d5bcdecdbb..f5b2c5e820 100644 --- a/packages/core/test/corpus/specCorpus.test.ts +++ b/packages/core/test/corpus/specCorpus.test.ts @@ -65,9 +65,6 @@ const ERROR_OBJECT_DIRS = new Set([ * fails loudly. These burn down as the corresponding features land. */ const PENDING_2026: Record = { - InputRequests: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', - InputRequiredResult: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', - InputResponses: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', SubscriptionsAcknowledgedNotification: 'subscriptions/listen vocabulary (SEP-1865) is not modeled yet', SubscriptionsListenRequest: 'subscriptions/listen vocabulary (SEP-1865) is not modeled yet' }; @@ -79,13 +76,9 @@ const PENDING_2026: Record = { * parse, so the entry is removed the moment the widening lands. */ const PENDING_2026_FILES: Record = { - // The draft removed elicitationId from ElicitRequestURLParams; the SDK's - // shared schema keeps it (it is required on the frozen 2025-11-25 - // revision), and the 2026-era in-band elicitation surface that will model - // the new shape is MRTR scope (#13). Until then the upstream example - // (which carries no elicitationId) does not parse. - 'ElicitRequestURLParams/elicit-sensitive-data.json': - 'URL-mode elicitation without elicitationId is modeled with the MRTR in-band surface (SEP-2322, #13)' + // (empty — the elicitationId-less ElicitRequestURLParams example burned + // when the 2026-era wire module landed the URL-mode elicitation fork as + // part of the multi-round-trip in-band vocabulary.) }; type AnyZod = z.ZodType; diff --git a/packages/core/test/shared/inputRequired.test.ts b/packages/core/test/shared/inputRequired.test.ts new file mode 100644 index 0000000000..421ed67309 --- /dev/null +++ b/packages/core/test/shared/inputRequired.test.ts @@ -0,0 +1,89 @@ +/** + * The multi-round-trip authoring helpers (M4.1): the `inputRequired()` + * builder family, the `acceptedContent` reader, and the `withInputRequired` + * manual-mode schema wrapper. No nominal brand exists — the builder returns a + * plain `resultType: 'input_required'` value (F-10). + */ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { acceptedContent, inputRequired, withInputRequired } from '../../src/shared/inputRequired.js'; +import { isInputRequiredResult } from '../../src/types/guards.js'; +import { validateStandardSchema } from '../../src/util/standardSchema.js'; + +describe('inputRequired() builder', () => { + test('builds a plain discriminated value (no brand) from inputRequests', () => { + const value = inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: 'OK?', requestedSchema: { type: 'object', properties: {} } }) } + }); + expect(value.resultType).toBe('input_required'); + expect(Object.getOwnPropertySymbols(value)).toEqual([]); + expect(isInputRequiredResult(value)).toBe(true); + expect(value.inputRequests?.confirm).toMatchObject({ method: 'elicitation/create', params: { mode: 'form', message: 'OK?' } }); + expect(value.requestState).toBeUndefined(); + }); + + test('builds a requestState-only value (load shedding)', () => { + const value = inputRequired({ requestState: 'opaque-blob' }); + expect(value).toEqual({ resultType: 'input_required', requestState: 'opaque-blob' }); + }); + + test('enforces the at-least-one rule', () => { + expect(() => inputRequired({})).toThrow(TypeError); + expect(() => inputRequired({ inputRequests: {} })).toThrow(/at least one/); + }); + + test('hand-built literals discriminate identically (hand-built results are legal)', () => { + expect(isInputRequiredResult({ resultType: 'input_required', requestState: 's' })).toBe(true); + expect(isInputRequiredResult({ resultType: 'complete' })).toBe(false); + expect(isInputRequiredResult({ content: [] })).toBe(false); + expect(isInputRequiredResult(null)).toBe(false); + }); + + test('per-kind constructors produce the embedded request shapes', () => { + expect(inputRequired.elicitUrl({ message: 'go', url: 'https://example.com/auth' })).toEqual({ + method: 'elicitation/create', + params: { mode: 'url', message: 'go', url: 'https://example.com/auth' } + }); + expect(inputRequired.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 5 })).toEqual({ + method: 'sampling/createMessage', + params: { messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 5 } + }); + expect(inputRequired.listRoots()).toEqual({ method: 'roots/list' }); + }); +}); + +describe('acceptedContent()', () => { + test('returns the accepted form content for the key', () => { + const responses = { confirm: { action: 'accept', content: { confirm: true } } }; + expect(acceptedContent<{ confirm: boolean }>(responses, 'confirm')).toEqual({ confirm: true }); + }); + + test('returns undefined for missing keys, declined/cancelled responses, and other kinds', () => { + expect(acceptedContent(undefined, 'confirm')).toBeUndefined(); + expect(acceptedContent({}, 'confirm')).toBeUndefined(); + expect(acceptedContent({ confirm: { action: 'decline' } }, 'confirm')).toBeUndefined(); + expect(acceptedContent({ confirm: { action: 'cancel' } }, 'confirm')).toBeUndefined(); + expect(acceptedContent({ confirm: { action: 'accept' } }, 'confirm')).toBeUndefined(); + expect(acceptedContent({ roots: { roots: [] } }, 'roots')).toBeUndefined(); + }); +}); + +describe('withInputRequired()', () => { + const inner = z.object({ content: z.array(z.unknown()) }); + + test('passes input-required values through untouched', async () => { + const wrapped = withInputRequired(inner); + const value = { resultType: 'input_required', requestState: 'blob' }; + const outcome = await validateStandardSchema(wrapped, value); + expect(outcome).toEqual({ success: true, data: value }); + }); + + test('validates complete results against the wrapped schema', async () => { + const wrapped = withInputRequired(inner); + const ok = await validateStandardSchema(wrapped, { content: [] }); + expect(ok.success).toBe(true); + const bad = await validateStandardSchema(wrapped, { nope: true }); + expect(bad.success).toBe(false); + }); +}); diff --git a/packages/core/test/shared/inputRequiredDriver.test.ts b/packages/core/test/shared/inputRequiredDriver.test.ts new file mode 100644 index 0000000000..6f49311060 --- /dev/null +++ b/packages/core/test/shared/inputRequiredDriver.test.ts @@ -0,0 +1,306 @@ +/** + * The multi-round-trip auto-fulfilment driver loop in isolation (M4.1): + * round accounting against the configurable cap, retry-param construction + * (byte-exact requestState echo, bare responses), requestState-only pacing, + * the existing-knob total-timeout bound, and the typed rounds-exceeded error + * carrying the last result. + */ +import { describe, expect, test, vi } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import { + buildInputRequiredRetryParams, + DEFAULT_INPUT_REQUIRED_AUTO_FULFILL, + DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, + REQUEST_STATE_ONLY_LEG_PACING_MS, + resolveInputRequiredDriverConfig, + runInputRequiredDriver +} from '../../src/shared/inputRequiredDriver.js'; + +const ELICIT_ENTRY = { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } }; + +describe('driver configuration', () => { + test('defaults: auto-fulfilment on, cap 10 rounds', () => { + expect(DEFAULT_INPUT_REQUIRED_AUTO_FULFILL).toBe(true); + expect(DEFAULT_INPUT_REQUIRED_MAX_ROUNDS).toBe(10); + expect(resolveInputRequiredDriverConfig(undefined)).toEqual({ autoFulfill: true, maxRounds: 10 }); + expect(resolveInputRequiredDriverConfig({ autoFulfill: false, maxRounds: 3 })).toEqual({ autoFulfill: false, maxRounds: 3 }); + }); +}); + +describe('retry params', () => { + test('echoes requestState byte-exact and attaches bare responses without touching original params', () => { + const original = { name: 'deploy', arguments: { env: 'prod' } }; + const params = buildInputRequiredRetryParams(original, { confirm: { action: 'accept', content: { ok: true } } }, 'opaqueÿ☃'); + expect(params).toEqual({ + name: 'deploy', + arguments: { env: 'prod' }, + inputResponses: { confirm: { action: 'accept', content: { ok: true } } }, + requestState: 'opaqueÿ☃' + }); + // The original params object is not mutated. + expect(original).toEqual({ name: 'deploy', arguments: { env: 'prod' } }); + }); + + test('omits requestState when the result carried none, and inputResponses when nothing was fulfilled', () => { + expect(buildInputRequiredRetryParams({ name: 'x' }, undefined, 'state')).toEqual({ name: 'x', requestState: 'state' }); + expect(buildInputRequiredRetryParams({ name: 'x' }, {}, undefined)).toEqual({ name: 'x' }); + expect(buildInputRequiredRetryParams(undefined, undefined, undefined)).toBeUndefined(); + }); +}); + +describe('driver loop', () => { + test('fulfils embedded requests, retries, and resolves with the complete result', async () => { + const dispatched: string[] = []; + const retries: Array | undefined> = []; + const result = await runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'deploy' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY }, requestState: 'round-1' }, + requestOptions: {}, + hooks: { + dispatchInputRequest: (key, _entry) => { + dispatched.push(key); + return Promise.resolve({ action: 'accept', content: { ok: true } }); + }, + retry: params => { + retries.push(params); + return Promise.resolve({ content: [{ type: 'text', text: 'done' }] }); + } + } + }); + + expect(result).toEqual({ content: [{ type: 'text', text: 'done' }] }); + expect(dispatched).toEqual(['confirm']); + expect(retries).toEqual([ + { + name: 'deploy', + inputResponses: { confirm: { action: 'accept', content: { ok: true } } }, + requestState: 'round-1' + } + ]); + }); + + test('keeps looping while retries return input_required and counts every leg against the cap', async () => { + let retryCount = 0; + const result = await runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 3 }, + method: 'tools/call', + originalParams: { name: 'deploy' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: {}, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept', content: {} }), + retry: () => { + retryCount += 1; + if (retryCount < 3) { + return Promise.resolve({ resultType: 'input_required', inputRequests: { confirm: ELICIT_ENTRY } }); + } + return Promise.resolve({ content: [] }); + } + } + }); + expect(result).toEqual({ content: [] }); + expect(retryCount).toBe(3); + }); + + test('round exhaustion raises the typed error carrying the last input_required payload', async () => { + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 2 }, + method: 'prompts/get', + originalParams: { name: 'p' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY }, requestState: 'state-0' }, + requestOptions: {}, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept' }), + retry: () => + Promise.resolve({ resultType: 'input_required', inputRequests: { again: ELICIT_ENTRY }, requestState: 'state-n' }) + } + }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + const typed = error as SdkError; + expect(typed.code).toBe(SdkErrorCode.InputRequiredRoundsExceeded); + expect(typed.data).toMatchObject({ + rounds: 2, + lastResult: { inputRequests: { again: ELICIT_ENTRY }, requestState: 'state-n' } + }); + return true; + }); + }); + + test('a requestState-only leg is paced by the fixed delay and counted in the same cap', async () => { + vi.useFakeTimers(); + try { + let resolved = false; + const run = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'x' }, + firstPayload: { inputRequests: {}, requestState: 'only-state' }, + requestOptions: {}, + hooks: { + dispatchInputRequest: () => Promise.reject(new Error('must not dispatch on a state-only leg')), + retry: params => { + expect(params).toEqual({ name: 'x', requestState: 'only-state' }); + return Promise.resolve({ content: [] }); + } + } + }).then(value => { + resolved = true; + return value; + }); + + // Nothing happens before the pacing delay elapses. + await vi.advanceTimersByTimeAsync(REQUEST_STATE_ONLY_LEG_PACING_MS - 1); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(2); + await expect(run).resolves.toEqual({ content: [] }); + } finally { + vi.useRealTimers(); + } + }); + + test('maxTotalTimeout bounds the whole flow through the existing knob (shrinking per-leg budgets)', async () => { + const legBudgets: Array = []; + let now = 0; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + try { + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'x' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: { timeout: 1_000, maxTotalTimeout: 5_000 }, + hooks: { + dispatchInputRequest: () => { + // Handler time counts against the total budget. + now += 3_000; + return Promise.resolve({ action: 'accept' }); + }, + retry: (_params, legOptions) => { + legBudgets.push(legOptions.maxTotalTimeout); + return Promise.resolve({ resultType: 'input_required', inputRequests: { confirm: ELICIT_ENTRY } }); + } + } + }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + return true; + }); + // First leg got the remaining 2 s of the 5 s budget; the second + // round's budget was already exhausted before sending. + expect(legBudgets).toEqual([2_000]); + } finally { + nowSpy.mockRestore(); + } + }); + + test('the total-timeout budget is measured from the flow start (the original request), not the driver start', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => 10_000); + try { + const retries: unknown[] = []; + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'x' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: { maxTotalTimeout: 5_000 }, + // The original request went out at t=4s; the first wire leg + // alone already exhausted the 5 s whole-flow budget by t=10s. + flowStartedAt: 4_000, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept' }), + retry: params => { + retries.push(params); + return Promise.resolve({ content: [] }); + } + } + }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + const typed = error as SdkError; + expect(typed.code).toBe(SdkErrorCode.RequestTimeout); + expect(typed.data).toMatchObject({ maxTotalTimeout: 5_000, totalElapsed: 6_000 }); + return true; + }); + // Fail before any retry hits the wire: the budget was already gone. + expect(retries).toHaveLength(0); + } finally { + nowSpy.mockRestore(); + } + }); + + test('each round is surfaced as synthetic progress to the caller', async () => { + const progress: number[] = []; + await runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'resources/read', + originalParams: { uri: 'file:///x' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: { onprogress: update => progress.push(update.progress) }, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept' }), + retry: () => Promise.resolve({ contents: [] }) + } + }); + expect(progress).toEqual([1]); + }); + + test('a failing embedded dispatch aborts its sibling dispatches via the per-round signal', async () => { + let siblingSignal: AbortSignal | undefined; + const siblingSettled = vi.fn(); + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 't' }, + firstPayload: { inputRequests: { fail: ELICIT_ENTRY, slow: ELICIT_ENTRY } }, + requestOptions: {}, + hooks: { + dispatchInputRequest: (key, _entry, signal) => { + if (key === 'fail') { + return Promise.reject(new SdkError(SdkErrorCode.CapabilityNotSupported, 'no handler')); + } + siblingSignal = signal; + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + siblingSettled(); + reject(signal.reason); + }); + }); + }, + retry: () => Promise.resolve({ content: [] }) + } + }); + await expect(outcome).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported }); + // The sibling was aborted via the linked per-round signal — it did not + // keep running after the first failure. + expect(siblingSignal?.aborted).toBe(true); + expect(siblingSettled).toHaveBeenCalledOnce(); + }); + + test('the requestState-only pacing sleep honors the caller abort signal', async () => { + const controller = new AbortController(); + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 't' }, + firstPayload: { inputRequests: {}, requestState: 'opaque' }, + requestOptions: {}, + signal: controller.signal, + hooks: { + dispatchInputRequest: () => Promise.resolve({}), + retry: () => Promise.resolve({ content: [] }) + } + }); + // Abort while the loop is in the 250 ms pacing sleep — the call must + // settle without waiting it out. + const aborted = new SdkError(SdkErrorCode.RequestTimeout, 'aborted'); + controller.abort(aborted); + const start = Date.now(); + await expect(outcome).rejects.toBe(aborted); + expect(Date.now() - start).toBeLessThan(REQUEST_STATE_ONLY_LEG_PACING_MS); + }); +}); diff --git a/packages/core/test/shared/inputRequiredEngine.test.ts b/packages/core/test/shared/inputRequiredEngine.test.ts new file mode 100644 index 0000000000..af4f9eeed7 --- /dev/null +++ b/packages/core/test/shared/inputRequiredEngine.test.ts @@ -0,0 +1,77 @@ +/** + * The multi-round-trip auto-fulfilment engine wiring (the layer between the + * funnel hook and the driver loop): the per-retry-leg request-options + * whitelist, the input-responses partition, and the synthesized embedded + * dispatch context. + */ +import { describe, expect, test } from 'vitest'; + +import { + buildRetryLegRequestOptions, + partitionInputResponses, + synthesizeInputRequestContext +} from '../../src/shared/inputRequiredEngine.js'; + +describe('per-retry-leg request options whitelist', () => { + test('only the whitelisted fields carry over — resumption tokens and the related-request id never do', () => { + const controller = new AbortController(); + const onprogress = (): void => undefined; + const onresumptiontoken = (): void => undefined; + const built = buildRetryLegRequestOptions( + { + signal: controller.signal, + onprogress, + resetTimeoutOnProgress: true, + timeout: 9_999, + maxTotalTimeout: 99_999, + relatedRequestId: 'outer', + resumptionToken: 'tok-123', + onresumptiontoken + }, + { timeout: 5_000, maxTotalTimeout: 60_000 } + ); + expect(built).toEqual({ + signal: controller.signal, + onprogress, + resetTimeoutOnProgress: true, + timeout: 5_000, + maxTotalTimeout: 60_000, + allowInputRequired: true + }); + // The originating call's transport-send options are scoped to the + // originating wire leg only. + expect('resumptionToken' in built).toBe(false); + expect('onresumptiontoken' in built).toBe(false); + expect('relatedRequestId' in built).toBe(false); + }); + + test('absent caller options yield only the manual primitive opt-in', () => { + expect(buildRetryLegRequestOptions(undefined, {})).toEqual({ allowInputRequired: true }); + }); +}); + +describe('inputResponses partition', () => { + test('bare entries are accepted; wrapped {method, result} entries and non-objects are dropped by key', () => { + const { accepted, droppedKeys } = partitionInputResponses({ + confirm: { action: 'accept', content: { ok: true } }, + wrapped: { method: 'elicitation/create', result: { action: 'accept' } }, + bad: 7 + }); + expect(accepted).toEqual({ confirm: { action: 'accept', content: { ok: true } } }); + expect(droppedKeys.sort()).toEqual(['bad', 'wrapped']); + }); +}); + +describe('synthesized embedded dispatch context', () => { + test('id is the inputRequests key, the supplied signal chains through, and related send/notify are unavailable', () => { + const controller = new AbortController(); + const ctx = synthesizeInputRequestContext('confirm', 'elicitation/create', { _meta: { x: 1 } }, controller.signal, 'sess-1'); + expect(ctx.mcpReq.id).toBe('confirm'); + expect(ctx.mcpReq.method).toBe('elicitation/create'); + expect(ctx.mcpReq.signal).toBe(controller.signal); + expect(ctx.sessionId).toBe('sess-1'); + expect(() => ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 0, progress: 1 } })).toThrowError( + /not available while fulfilling an embedded input request/ + ); + }); +}); diff --git a/packages/core/test/shared/inputRequiredFunnel.test.ts b/packages/core/test/shared/inputRequiredFunnel.test.ts new file mode 100644 index 0000000000..c08904ed7e --- /dev/null +++ b/packages/core/test/shared/inputRequiredFunnel.test.ts @@ -0,0 +1,162 @@ +/** + * Protocol-layer seams of the multi-round-trip flow (M4.1): + * + * - the manual path: `allowInputRequired: true` hands the discriminated + * input-required value back to the caller (the primitive the auto driver is + * layered over), discriminated raw and BEFORE any consumer schema runs; + * - the inbound retry-material partition: only BARE inputResponses entries + * surface to handlers; wrapped `{method, result}` entries are dropped into + * `ctx.mcpReq.droppedInputResponseKeys` (T1/D-059). + */ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import type { JSONRPCRequest } from '../../src/types/index.js'; +import { isInputRequiredResult } from '../../src/types/guards.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import { rev2026Codec } from '../../src/wire/rev2026-07-28/codec.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const INPUT_REQUIRED_BODY = { + resultType: 'input_required', + inputRequests: { 'elicit-1': { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } }, + requestState: 'opaque-state' +}; + +async function wireWithRawResult(rawResult: unknown): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + void serverTx.send({ jsonrpc: '2.0', id: request.id, result: rawResult } as Parameters[0]); + }; + await serverTx.start(); + const protocol = new TestProtocol(); + await protocol.connect(clientTx); + setNegotiatedProtocolVersion(protocol, '2026-07-28'); + return protocol; +} + +describe('manual mode (allowInputRequired)', () => { + test('hands the discriminated input-required value back to the caller', async () => { + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + + const result = await protocol.request( + { method: 'tools/call', params: { name: 'echo', arguments: {} } }, + { + allowInputRequired: true + } + ); + + expect(isInputRequiredResult(result)).toBe(true); + expect(result).toEqual({ + resultType: 'input_required', + inputRequests: INPUT_REQUIRED_BODY.inputRequests, + requestState: 'opaque-state' + }); + + await protocol.close(); + }); + + test('discrimination happens on the raw body, before the consumer-provided result schema runs', async () => { + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + + let schemaInvoked = false; + const poisonedSchema = z.unknown().transform(value => { + schemaInvoked = true; + return value; + }); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo' } }, poisonedSchema, { + allowInputRequired: true + }); + expect(isInputRequiredResult(result)).toBe(true); + expect(schemaInvoked, 'the consumer schema must never see the input_required body').toBe(false); + + await protocol.close(); + }); + + test('without the opt-in (and without a driver) the typed local error is unchanged', async () => { + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + await expect(protocol.request({ method: 'tools/call', params: { name: 'echo' } })).rejects.toMatchObject({ + code: 'UNSUPPORTED_RESULT_TYPE', + data: { resultType: 'input_required', method: 'tools/call' } + }); + await protocol.close(); + }); + + test('an input_required carrying neither inputRequests nor requestState fails fast as an invalid result, even with the opt-in', async () => { + const protocol = await wireWithRawResult({ resultType: 'input_required' }); + await expect( + protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }, { allowInputRequired: true }) + ).rejects.toMatchObject({ + code: 'INVALID_RESULT', + data: { method: 'tools/call', violation: 'input-required-missing-both' } + }); + await protocol.close(); + }); +}); + +describe('era gate (in-band vocabulary grants no registry membership)', () => { + test('the demoted methods are absent from the 2026-07-28 wire-request registry even though their in-band schemas exist', () => { + for (const method of ['elicitation/create', 'sampling/createMessage', 'roots/list']) { + expect(rev2026Codec.inputRequestSchema(method), method).toBeDefined(); + // A peer sending one of these as a wire request on the 2026 era + // still answers −32601 by absence — the in-band fallback used for + // embedded dispatch must never grant wire-request membership. + expect(rev2026Codec.hasRequestMethod(method), method).toBe(false); + } + }); +}); + +describe('inbound retry material (T1/D-059)', () => { + test('bare entries surface on ctx.mcpReq.inputResponses; wrapped entries are dropped into droppedInputResponseKeys', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const receiver = new TestProtocol(); + const seen: Array = []; + receiver.setRequestHandler('tools/call', (_request, ctx) => { + seen.push(ctx.mcpReq); + return { content: [] }; + }); + await receiver.connect(serverTx); + await clientTx.start(); + + const responses = new Promise(resolve => { + clientTx.onmessage = () => resolve(); + }); + await clientTx.send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'deploy', + arguments: {}, + inputResponses: { + bare: { action: 'accept', content: { ok: true } }, + wrapped: { method: 'elicitation/create', result: { action: 'accept' } }, + 'not-an-object': 42 + }, + requestState: 'echoed-back' + } + } as Parameters[0]); + await responses; + + expect(seen).toHaveLength(1); + const mcpReq = seen[0]!; + expect(mcpReq.inputResponses).toEqual({ bare: { action: 'accept', content: { ok: true } } }); + expect(mcpReq.droppedInputResponseKeys?.sort()).toEqual(['not-an-object', 'wrapped']); + expect(mcpReq.requestState).toBe('echoed-back'); + // The handler-visible params never carry the lifted retry material. + await receiver.close(); + await clientTx.close(); + }); +}); diff --git a/packages/core/test/spec.types.2026-07-28.test.ts b/packages/core/test/spec.types.2026-07-28.test.ts index 02ab034651..23167146ae 100644 --- a/packages/core/test/spec.types.2026-07-28.test.ts +++ b/packages/core/test/spec.types.2026-07-28.test.ts @@ -83,6 +83,44 @@ type WCancelledNotification = z4.infer; type WNotificationMeta = z4.infer; +/* Multi-round-trip vocabulary (SEP-2322) — modeled by the 2026-era wire module. */ +type WInputRequest = z4.infer; +type WInputRequests = z4.infer; +type WInputResponse = z4.infer; +type WInputResponses = z4.infer; +type WInputRequiredResult = z4.infer; +type WInputResponseRequestParams = z4.infer; +type WCreateMessageRequest = z4.infer; +type WCreateMessageRequestParams = z4.infer; +type WCreateMessageResult = z4.infer; +type WElicitRequest = z4.infer; +type WElicitRequestParams = z4.infer; +type WElicitRequestURLParams = z4.infer; +type WElicitResult = z4.infer; +type WListRootsRequest = z4.infer; +type WListRootsResult = z4.infer; +type WCallToolRequest = z4.infer; +type WCallToolRequestParams = WCallToolRequest['params']; +type WGetPromptRequest = z4.infer; +type WGetPromptRequestParams = WGetPromptRequest['params']; +type WReadResourceRequestParamsRetry = WReadResourceRequest['params']; +type WCallToolResultResponse = z4.infer; +type WGetPromptResultResponse = z4.infer; +type WReadResourceResultResponse = z4.infer; +// The anchor's ServerResult union, composed from the era module's wire results. +type WServerResult = + | WResult + | WDiscoverResult + | WCompleteResult + | WGetPromptResult + | WListPromptsResult + | WListResourceTemplatesResult + | WListResourcesResult + | WReadResourceResult + | WCallToolResult + | WListToolsResult + | WInputRequiredResult; + const sdkTypeChecks = { JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { sdk = spec; @@ -590,6 +628,111 @@ const wireParityChecks = { ListToolsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest) => { sdk = spec; spec = sdk; + }, + + /* Multi-round-trip vocabulary (SEP-2322, M4.1) */ + InputRequest: (sdk: WInputRequest, spec: SpecTypes.InputRequest) => { + sdk = spec; + spec = sdk; + }, + InputRequests: (sdk: WInputRequests, spec: SpecTypes.InputRequests) => { + sdk = spec; + spec = sdk; + }, + InputResponse: (sdk: WInputResponse, spec: SpecTypes.InputResponse) => { + sdk = spec; + spec = sdk; + }, + InputResponses: (sdk: WInputResponses, spec: SpecTypes.InputResponses) => { + sdk = spec; + spec = sdk; + }, + InputRequiredResult: (sdk: WInputRequiredResult, spec: SpecTypes.InputRequiredResult) => { + sdk = spec; + spec = sdk; + }, + InputResponseRequestParams: (sdk: WInputResponseRequestParams, spec: SpecTypes.InputResponseRequestParams) => { + sdk = spec; + spec = sdk; + }, + CreateMessageRequest: (sdk: WCreateMessageRequest, spec: SpecTypes.CreateMessageRequest) => { + sdk = spec; + spec = sdk; + }, + CreateMessageRequestParams: (sdk: WCreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { + sdk = spec; + spec = sdk; + }, + CreateMessageResult: (sdk: WCreateMessageResult, spec: SpecTypes.CreateMessageResult) => { + sdk = spec; + spec = sdk; + }, + // The 2026-era URL-mode elicitation params drop `elicitationId` (the + // shared schema keeps it required for the frozen 2025-11-25 shape) — + // compared against the wire-module fork. + ElicitRequestURLParams: (sdk: WElicitRequestURLParams, spec: SpecTypes.ElicitRequestURLParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestParams: (sdk: WElicitRequestParams, spec: SpecTypes.ElicitRequestParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequest: (sdk: WElicitRequest, spec: SpecTypes.ElicitRequest) => { + sdk = spec; + spec = sdk; + }, + ElicitResult: (sdk: WElicitResult, spec: SpecTypes.ElicitResult) => { + sdk = spec; + spec = sdk; + }, + ListRootsRequest: (sdk: WListRootsRequest, spec: SpecTypes.ListRootsRequest) => { + sdk = spec; + spec = sdk; + }, + ListRootsResult: (sdk: WListRootsResult, spec: SpecTypes.ListRootsResult) => { + sdk = spec; + spec = sdk; + }, + CallToolRequestParams: (sdk: WCallToolRequestParams, spec: SpecTypes.CallToolRequestParams) => { + sdk = spec; + spec = sdk; + }, + CallToolRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CallToolRequest) => { + sdk = spec; + spec = sdk; + }, + GetPromptRequestParams: (sdk: WGetPromptRequestParams, spec: SpecTypes.GetPromptRequestParams) => { + sdk = spec; + spec = sdk; + }, + GetPromptRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetPromptRequest) => { + sdk = spec; + spec = sdk; + }, + ReadResourceRequestParams: (sdk: WReadResourceRequestParamsRetry, spec: SpecTypes.ReadResourceRequestParams) => { + sdk = spec; + spec = sdk; + }, + ReadResourceRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ReadResourceRequest) => { + sdk = spec; + spec = sdk; + }, + CallToolResultResponse: (sdk: WCallToolResultResponse, spec: SpecTypes.CallToolResultResponse) => { + sdk = spec; + spec = sdk; + }, + GetPromptResultResponse: (sdk: WGetPromptResultResponse, spec: SpecTypes.GetPromptResultResponse) => { + sdk = spec; + spec = sdk; + }, + ReadResourceResultResponse: (sdk: WReadResourceResultResponse, spec: SpecTypes.ReadResourceResultResponse) => { + sdk = spec; + spec = sdk; + }, + ServerResult: (sdk: WServerResult, spec: SpecTypes.ServerResult) => { + sdk = spec; + spec = sdk; } }; @@ -608,36 +751,9 @@ const FEATURE_OWNED_PENDING_2026: Record = { // Inlined in the SDK (same as the 2025-11-25 comparison): Error: 'the inner error object of a JSONRPCError is inlined in the SDK', - // M4.1 MRTR (#13): the in-band input-request surface and the demoted - // sampling/elicitation/roots shapes (wire requests in 2025, in-band - // InputRequest payloads in 2026 — the SDK models them when the - // multi-round-trip driver lands): - InputRequest: 'M4.1 MRTR (#13)', - InputRequests: 'M4.1 MRTR (#13)', - InputRequiredResult: 'M4.1 MRTR (#13)', - InputResponse: 'M4.1 MRTR (#13)', - InputResponseRequestParams: 'M4.1 MRTR (#13)', - InputResponses: 'M4.1 MRTR (#13)', - CreateMessageRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', - CreateMessageRequestParams: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', - CreateMessageResult: 'M4.1 MRTR (#13) — in-band response shape', - ElicitRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', - ElicitRequestParams: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', - ElicitRequestURLParams: - 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026; the draft also removed elicitationId from the URL-mode shape', - ElicitResult: 'M4.1 MRTR (#13) — in-band response shape', - ListRootsRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', - ListRootsResult: 'M4.1 MRTR (#13) — in-band response shape', - ServerResult: 'M4.1 MRTR (#13) — the union gains InputRequiredResult', - CallToolRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - CallToolRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - GetPromptRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - GetPromptRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - ReadResourceRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - ReadResourceRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', - CallToolResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', - GetPromptResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', - ReadResourceResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', + // (The M4.1 MRTR partition burned down when the multi-round-trip wire + // vocabulary landed in wire/rev2026-07-28 — see the wireParityChecks + // entries for InputRequest/InputRequiredResult/… above.) // M6.1 subscriptions/listen (#14): SubscriptionFilter: 'M6.1 subscriptions/listen (#14)', diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts index 46003004e4..bfd6730385 100644 --- a/packages/core/test/types/errorSurfacePins.test.ts +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -75,6 +75,7 @@ describe('SdkErrorCode', () => { SendFailed: 'SEND_FAILED', InvalidResult: 'INVALID_RESULT', UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', + InputRequiredRoundsExceeded: 'INPUT_REQUIRED_ROUNDS_EXCEEDED', MethodNotSupportedByProtocolVersion: 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', EraNegotiationFailed: 'ERA_NEGOTIATION_FAILED', ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', diff --git a/test/e2e/scenarios/raw-result-type.test.ts b/test/e2e/scenarios/raw-result-type.test.ts index ee5b454763..2307336980 100644 --- a/test/e2e/scenarios/raw-result-type.test.ts +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -129,11 +129,13 @@ verifies('typescript:client:raw-result-type-first', async ({ transport }: TestAr // ---- Modern negotiation: the client pins the draft revision, the relay // advertises it via server/discover → 2026 era → V-1 discrimination in - // the codec. ---- + // the codec. Auto-fulfilment is disabled here so this requirement keeps + // proving the discrimination surface itself (the typed local error); the + // multi-round-trip driver has its own requirements (typescript:mrtr:*). ---- { const client = new Client( { name: 'raw-result-type-client', version: '0' }, - { versionNegotiation: { mode: { pin: '2026-07-28' } } } + { versionNegotiation: { mode: { pin: '2026-07-28' } }, inputRequired: { autoFulfill: false } } ); await (transport === 'inMemory' ? connectInMemory(client, INPUT_REQUIRED_BODY) From 0dfc5ce01f62a077728616f816814b6acefbae19 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:02:19 +0100 Subject: [PATCH 26/37] =?UTF-8?q?feat:=20multi=20round-trip=20requests=20?= =?UTF-8?q?=E2=80=94=20server=20seam,=20e2e=20coverage,=20docs=20and=20exa?= =?UTF-8?q?mples=20(#2314)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/mrtr-server-seam.md | 12 + docs/migration-SKILL.md | 95 ++-- docs/migration.md | 256 ++++++---- examples/client/README.md | 1 + examples/client/src/multiRoundTripClient.ts | 124 +++++ examples/server/README.md | 1 + examples/server/src/multiRoundTrip.ts | 135 ++++++ .../shared/clientCapabilityRequirements.ts | 65 ++- .../core/src/shared/inboundClassification.ts | 6 + .../clientCapabilityRequirements.test.ts | 38 ++ packages/server/src/index.ts | 6 + packages/server/src/server/mcp.ts | 72 ++- packages/server/src/server/server.ts | 274 ++++++++++- .../server/test/server/inputRequired.test.ts | 455 ++++++++++++++++++ test/e2e/helpers/index.ts | 7 +- test/e2e/helpers/wire-sniffer.test.ts | 13 + test/e2e/helpers/wire-sniffer.ts | 14 + test/e2e/requirements.ts | 60 ++- test/e2e/scenarios/mrtr.test.ts | 233 +++++++++ 19 files changed, 1693 insertions(+), 174 deletions(-) create mode 100644 .changeset/mrtr-server-seam.md create mode 100644 examples/client/src/multiRoundTripClient.ts create mode 100644 examples/server/src/multiRoundTrip.ts create mode 100644 packages/server/test/server/inputRequired.test.ts create mode 100644 test/e2e/scenarios/mrtr.test.ts diff --git a/.changeset/mrtr-server-seam.md b/.changeset/mrtr-server-seam.md new file mode 100644 index 0000000000..861d7df9b6 --- /dev/null +++ b/.changeset/mrtr-server-seam.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +Add the server side of multi round-trip requests (protocol revision 2026-07-28, SEP-2322). Handlers for `tools/call`, `prompts/get`, and `resources/read` can return the value built by `inputRequired()` (exported from the server package together with `acceptedContent()`) +to request additional client input in-band; the structured-content requirement and the tools/call result-schema validation are skipped for that return, the encode seam emits it as `resultType: 'input_required'`, and the handler reads the responses on re-entry from +`ctx.mcpReq.inputResponses` (with non-bare entries reported via `ctx.mcpReq.droppedInputResponseKeys`). The seam re-checks the at-least-one rule for hand-built results, checks every embedded request against the capabilities the client declared on that request's envelope +(answering the typed `-32003` error on violation), and fails loudly — never emitting a mis-typed result — when an input-required value is returned from any other method or toward a 2025-era request. A `UrlElicitationRequiredError` escaping a handler on a 2026-era request +fails as an internal error with a clear steer to `inputRequired.elicitUrl(...)`, so the `-32042` error never reaches the 2026-07-28 wire; 2025-era serving keeps today's `-32042` behavior +exactly. The typed local error raised when push-style server-to-client request APIs are used while serving a 2026-era request now steers to `inputRequired(...)`. Tool, prompt, and resource callback types accept the new return alongside their existing result types; 2025-era +wire behavior is unchanged. An optional `ServerOptions.requestState.verify` hook lets a server integrity-check the echoed `requestState` before the handler runs — a throw answers the wire-level `-32602` Invalid Params error with `data.reason: 'invalid_request_state'`; the SDK provides no default verification. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index b44c5a1f80..14d9330dfd 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -501,34 +501,58 @@ The 2025-11 task side-channel through `Protocol` is removed (was always `@experi `TaskStore` / `InMemoryTaskStore` / `CreateTaskOptions` / `isTerminal` (storage layer) are also removed; they will return with the SEP-2663 server-directed plugin. -NOT removed (wire surface, kept for 2025-11-25 interop, now `@deprecated`): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), task members of the request/result/notification union types, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`. +NOT removed (wire surface, kept for 2025-11-25 interop, now `@deprecated`): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, +`TaskAugmentedRequestParams`), task members of the request/result/notification union types, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`. -Task methods are excluded from the typed method maps: `RequestMethod`/`RequestTypeMap`/`ResultTypeMap` have no `tasks/*` entries and `NotificationMethod`/`NotificationTypeMap` have no `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task methods at compile time. Mechanical fix where task interop is genuinely required: pass an explicit schema (`request({ method: 'tasks/get', params }, GetTaskResultSchema)`-style custom-method form). `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. +Task methods are excluded from the typed method maps: `RequestMethod`/`RequestTypeMap`/`ResultTypeMap` have no `tasks/*` entries and `NotificationMethod`/`NotificationTypeMap` have no `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, +`setRequestHandler()`, `setNotificationHandler()` reject task methods at compile time. Mechanical fix where task interop is genuinely required: pass an explicit schema (`request({ method: 'tasks/get', params }, GetTaskResultSchema)`-style custom-method form). +`ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. ## 12b. Wire-only members hidden from public types -`resultType` (2026-07-28 result discrimination) is no longer declared on any public result type; the SDK parses and consumes it internally. The reserved `_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`) and retry fields (`inputResponses`, `requestState`) appear in no public params/result type. `RequestMetaEnvelope` and the `*_META_KEY` constants remain exported. +`resultType` (2026-07-28 result discrimination) is no longer declared on any public result type; the SDK parses and consumes it internally. The reserved `_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`) and retry fields +(`inputResponses`, `requestState`) appear in no public params/result type. `RequestMetaEnvelope` and the `*_META_KEY` constants remain exported. -| Pattern in v2-alpha code | Mechanical fix | -| ------------------------------------- | --------------------------------------------------------------------------------- | -| `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered | -| `Result['resultType']` type reference | remove; the member is no longer declared | -| return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | +v1 code never reads `resultType` (the field did not exist before 2026-07-28); the table below applies only to code that began reading the wire shape directly. -Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. On a 2026-era exchange a response carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`), while on a 2025-era connection a foreign `resultType` is stripped before validation; the serving wire era is the instance's negotiated protocol version (connection state), and `MessageExtraInfo.classification` is only validated against it at dispatch (a mismatch is rejected as an entry/routing error). Collision note for 2025-era peers: 2025-11-25 reserves the `io.modelcontextprotocol/` `_meta` prefix but NOT the bare names `inputResponses`/`requestState`, so a 2025 peer's custom-method request using those names as ordinary params has them lifted out of `request.params` (recoverable via ctx; everything else passes through untouched). +| Pattern | Mechanical fix | +| -------------------------------------- | --------------------------------------------------------------------------------- | +| `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered | +| `Result['resultType']` type reference | remove; the member is no longer declared | +| return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | + +Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted +envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. On a 2026-era exchange a response +carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`), while on a 2025-era connection a foreign `resultType` is stripped before validation; the serving wire era is the instance's negotiated protocol version +(connection state), and `MessageExtraInfo.classification` is only validated against it at dispatch (a mismatch is rejected as an entry/routing error). Collision note for 2025-era peers: 2025-11-25 reserves the `io.modelcontextprotocol/` `_meta` prefix but NOT the bare names +`inputResponses`/`requestState`, so a 2025 peer's custom-method request using those names as ordinary params has them lifted out of `request.params` (recoverable via ctx; everything else passes through untouched). ## 12c. Per-era wire codecs (physical deletions + stricter wire schemas) -The wire layer is split into per-era codecs (2025-era = 2024-10-07 … 2025-11-25; 2026-era = 2026-07-28). Era-mismatched spec methods fail physically: inbound -> `-32601` even with a handler registered; outbound -> `SdkError` code `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` before the transport. +The wire layer is split into per-era codecs (2025-era = 2024-10-07 … 2025-11-25; 2026-era = 2026-07-28). Era-mismatched spec methods fail physically: inbound -> `-32601` even with a handler registered; outbound -> `SdkError` code `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` before +the transport. + +| v1 pattern | Mechanical fix | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| tool handler returns without `content` | add `content: []` (or real content) — results without it are rejected `-32602`, no longer defaulted | +| parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) | +| strict custom-handler params schema (3-arg `setRequestHandler`/`setNotification…`) | add optional `_meta` to the schema (or strip it) — `_meta` is now passed through minus reserved keys | +| `specTypeSchemas`/`SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (types remain importable) | +| `ClientRequest`/`ServerResult`/… aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets | +| relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse | + +## 12d. Multi round-trip requests (2026-07-28) + +The 2026-07-28 revision removes the server→client JSON-RPC request channel; servers obtain client input in-band by returning `inputRequired(...)` from a `tools/call`/`prompts/get`/`resources/read` handler, and the client's auto-fulfilment driver retries the original call. + +| v1 pattern (handler serving 2026-07-28 requests) | Mechanical fix | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` inside a handler | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry | +| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` | +| handler shared across both eras | branch on the served era: keep the v1 push-style call toward 2025-era requests, return `inputRequired(...)` toward 2026-07-28 requests | -| Pattern in v2-alpha code | Mechanical fix | -| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | -| tool handler returns without `content` | add `content: []` (or real content) — results without it are rejected `-32602`, no longer defaulted | -| parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) | -| strict custom-handler params schema (3-arg `setRequestHandler`/`setNotification…`) | add optional `_meta` to the schema (or strip it) — `_meta` is now passed through minus reserved keys | -| `specTypeSchemas`/`SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (types remain importable) | -| `ClientRequest`/`ServerResult`/… aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets | -| relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse | +`inputRequired`/`acceptedContent`/`InputRequiredSpec` are exported from `@modelcontextprotocol/server`. `requestState` round-trips as an opaque string and comes back as attacker-controlled input — integrity-protect (HMAC/AEAD) and verify it yourself when relying on it. Client +side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRounds` cap default 10); manual mode is `inputRequired: { autoFulfill: false }` plus per-call `allowInputRequired: true` and `withInputRequired(schema)`. ## 13. Behavioral Changes @@ -540,34 +564,43 @@ The wire layer is split into per-era codecs (2025-era = 2024-10-07 … 2025-11-2 No code changes required; these are wire-behavior notes: -- Resumability behavior (SSE priming events, `closeSSEStream` / `closeStandaloneSSEStream` callbacks) is only enabled for protocol versions in the transport's supported-versions list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` request body no longer enable it. Behavior for all currently supported protocol versions is unchanged. -- Session-ID mismatch still responds `404 Not Found` with JSON-RPC error code `-32001` (`Session not found`), unchanged from v1. This `-32001` usage is an SDK convention, not a spec-assigned code, and may be re-derived as 2026 protocol revision error handling is adopted — migrated client code should key off the HTTP `404` status, not the `-32001` code. +- Resumability behavior (SSE priming events, `closeSSEStream` / `closeStandaloneSSEStream` callbacks) is only enabled for protocol versions in the transport's supported-versions list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` request body no + longer enable it. Behavior for all currently supported protocol versions is unchanged. +- Session-ID mismatch still responds `404 Not Found` with JSON-RPC error code `-32001` (`Session not found`), unchanged from v1. This `-32001` usage is an SDK convention, not a spec-assigned code, and may be re-derived as 2026 protocol revision error handling is adopted — + migrated client code should key off the HTTP `404` status, not the `-32001` code. ### Server (deprecated accessors and app-factory Origin validation) These can require code changes: -- `Server.getClientCapabilities()`, `getClientVersion()` and `getNegotiatedProtocolVersion()` are deprecated but functional: prefer the per-request context (`ctx.mcpReq.envelope`) on 2026-07-28 requests. No mechanical change required yet; plan the move before the deprecations are removed. -- `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default (requests without an `Origin` header are unaffected). Browser-served clients on a non-localhost origin need `allowedOrigins: [...]`, which replaces the default localhost allowlist — Origin validation cannot be disabled for localhost-class binds. +- `Server.getClientCapabilities()`, `getClientVersion()` and `getNegotiatedProtocolVersion()` are deprecated but functional: prefer the per-request context (`ctx.mcpReq.envelope`) on 2026-07-28 requests. No mechanical change required yet; plan the move before the deprecations are + removed. +- `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default (requests without an `Origin` header are unaffected). Browser-served clients on a non-localhost origin need + `allowedOrigins: [...]`, which replaces the default localhost allowlist — Origin validation cannot be disabled for localhost-class binds. ### Server (HTTP entry: createMcpHandler — serving the 2026-07-28 draft revision) New in 2.0 — v1 has no equivalent API. How v1 Streamable HTTP hosting maps onto the entry: -- `createMcpHandler(factory)` from `@modelcontextprotocol/server` serves the 2026-07-28 draft revision per request and, out of the box, also serves 2025-era (non-envelope) traffic through per-request stateless serving (`legacy: 'stateless'`, the default) — one factory, one endpoint, both eras. A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, fresh transport per request) maps directly onto the default entry. -- Pass `legacy: 'reject'` for a strict, modern-only endpoint: 2025-era requests are rejected with the unsupported-protocol-version error naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. The option type is `legacy?: 'stateless' | 'reject'`. -- An existing sessionful v1 Streamable HTTP setup (a `StreamableHTTPServerTransport` wiring with session IDs) keeps serving 2025 clients by routing in user land in front of a strict entry: `if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); return strictHandler.fetch(request)` where `strictHandler = createMcpHandler(factory, { legacy: 'reject' })`. -- `isLegacyRequest(request: Request, parsedBody?: unknown): Promise` from `@modelcontextprotocol/server` is the entry's own classification step. Returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler, never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed. +- `createMcpHandler(factory)` from `@modelcontextprotocol/server` serves the 2026-07-28 draft revision per request and, out of the box, also serves 2025-era (non-envelope) traffic through per-request stateless serving (`legacy: 'stateless'`, the default) — one factory, one + endpoint, both eras. A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, fresh transport per request) maps directly onto the default entry. +- Pass `legacy: 'reject'` for a strict, modern-only endpoint: 2025-era requests are rejected with the unsupported-protocol-version error naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. The option type is + `legacy?: 'stateless' | 'reject'`. +- An existing sessionful v1 Streamable HTTP setup (a `StreamableHTTPServerTransport` wiring with session IDs) keeps serving 2025 clients by routing in user land in front of a strict entry: + `if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); return strictHandler.fetch(request)` where `strictHandler = createMcpHandler(factory, { legacy: 'reject' })`. +- `isLegacyRequest(request: Request, parsedBody?: unknown): Promise` from `@modelcontextprotocol/server` is the entry's own classification step. Returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, + GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler, + never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed. - `legacyStatelessFallback(factory)` is exported as a standalone fetch-shaped handler producing the same stateless legacy serving as the default. ### Server (stdio / long-lived connections) - A hand-constructed `Server`/`McpServer` connected to a `StdioServerTransport` serves only the 2025-era protocol it was written for: today's behavior, byte-identical — no change required during a mechanical migration. -- Serving the 2026-07-28 draft revision (or both eras) on stdio goes through the connection-pinned entry: `serveStdio(() => new McpServer(info, options))` from `@modelcontextprotocol/server/stdio`. The opening exchange selects the connection's era (2025 `initialize` vs - 2026 per-request envelope, with `server/discover` answered as a probe); one factory instance is pinned per connection. There is no per-instance option that makes a hand-constructed server serve the 2026 revision: move the v1 `server.connect(new StdioServerTransport())` - call into `serveStdio(() => buildServer())`. `serveStdio(factory, { legacy: 'reject' })` refuses 2025-era openings with the unsupported-protocol-version error. -- On 2026-pinned stdio connections `getClientCapabilities()` / `getClientVersion()` return `undefined` (no `initialize` ever runs there) and handlers read per-request identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned revision - (`2026-07-28`), as on instances served through `createMcpHandler`. 2025-pinned connections keep the `initialize`-scoped semantics for all three accessors. +- Serving the 2026-07-28 draft revision (or both eras) on stdio goes through the connection-pinned entry: `serveStdio(() => new McpServer(info, options))` from `@modelcontextprotocol/server/stdio`. The opening exchange selects the connection's era (2025 `initialize` vs 2026 + per-request envelope, with `server/discover` answered as a probe); one factory instance is pinned per connection. There is no per-instance option that makes a hand-constructed server serve the 2026 revision: move the v1 `server.connect(new StdioServerTransport())` call into + `serveStdio(() => buildServer())`. `serveStdio(factory, { legacy: 'reject' })` refuses 2025-era openings with the unsupported-protocol-version error. +- On 2026-pinned stdio connections `getClientCapabilities()` / `getClientVersion()` return `undefined` (no `initialize` ever runs there) and handlers read per-request identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned revision (`2026-07-28`), + as on instances served through `createMcpHandler`. 2025-pinned connections keep the `initialize`-scoped semantics for all three accessors. - A client whose connection negotiated a modern era drops inbound server→client JSON-RPC requests (the 2026 era has no such channel) instead of answering them; legacy-era connections are unchanged. ## 14. Runtime-Specific JSON Schema Validators (Enhancement) diff --git a/docs/migration.md b/docs/migration.md index cd51f7dbbc..688d4e4bbe 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -336,11 +336,11 @@ Note: the v2 signature takes a plain `string[]` instead of an options object. ### Resumability gating for unknown protocol versions (Streamable HTTP server) -The server-side Streamable HTTP transport enables resumability behavior introduced with protocol version `2025-11-25` — SSE priming events and the `closeSSEStream` / `closeStandaloneSSEStream` callbacks — based on the client's protocol version. Previously this was an -open-ended `protocolVersion >= '2025-11-25'` comparison, so an unrecognized future version string in an `initialize` request body (which, unlike the `MCP-Protocol-Version` header, is not validated against the supported-versions list) silently enabled the behavior. +The server-side Streamable HTTP transport enables resumability behavior introduced with protocol version `2025-11-25` — SSE priming events and the `closeSSEStream` / `closeStandaloneSSEStream` callbacks — based on the client's protocol version. Previously this was an open-ended +`protocolVersion >= '2025-11-25'` comparison, so an unrecognized future version string in an `initialize` request body (which, unlike the `MCP-Protocol-Version` header, is not validated against the supported-versions list) silently enabled the behavior. -The check is now bounded: the version must be one of the transport's supported protocol versions (after `connect()`, the server's `supportedProtocolVersions`) **and** at least `2025-11-25`. Behavior for all currently supported protocol versions (`2024-10-07` through -`2025-11-25`) is unchanged. Clients claiming an unknown future protocol version in the initialize body are now treated like clients without empty-SSE-data support: no priming event is sent and no early-close callbacks are provided. +The check is now bounded: the version must be one of the transport's supported protocol versions (after `connect()`, the server's `supportedProtocolVersions`) **and** at least `2025-11-25`. Behavior for all currently supported protocol versions (`2024-10-07` through `2025-11-25`) +is unchanged. Clients claiming an unknown future protocol version in the initialize body are now treated like clients without empty-SSE-data support: no priming event is sent and no early-close callbacks are provided. ### `setRequestHandler` and `setNotificationHandler` use method strings @@ -902,57 +902,67 @@ The 2025-11 experimental tasks side-channel woven through `Protocol` has been re **Also removed:** the storage layer (`TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`). It will return as part of the SEP-2663 server-directed plugin in a follow-up. -**Wire types remain, as deprecated vocabulary.** The task wire surface defined by the 2025-11-25 protocol revision is still exported, for interoperability with peers on that revision: the task Zod schemas and their inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `GetTaskPayload*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification union types, the `tasks` capability key, the `isTaskAugmentedRequestParams` guard, and `RELATED_TASK_META_KEY`. These exports are now marked `@deprecated` (importable wire vocabulary only; removable at the major version that drops 2025-era support), and the typed method surface no longer offers task methods: `RequestMethod`/`RequestTypeMap`/`ResultTypeMap`/`NotificationTypeMap` exclude `tasks/*` and `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, and `setNotificationHandler()` do not accept them (the explicit-schema overloads still work for custom interop). The method-keyed result types are narrowed to match: `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`), and likewise `sampling/createMessage` and `elicitation/create` lose their task-result union members — the runtime result validation uses the same plain schemas, so a task-shaped response body to one of these methods fails as a local `INVALID_RESULT` error where the result schema rejects it rather than parsing into a mis-typed success. Only the behavior is gone: servers built on this SDK do not advertise the `tasks` capability, and inbound `tasks/*` requests receive a standard `-32601` (method not found) error. +**Wire types remain, as deprecated vocabulary.** The task wire surface defined by the 2025-11-25 protocol revision is still exported, for interoperability with peers on that revision: the task Zod schemas and their inferred types (`Task`, `TaskStatus`, `TaskMetadata`, +`RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `GetTaskPayload*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification union types, the `tasks` capability key, the +`isTaskAugmentedRequestParams` guard, and `RELATED_TASK_META_KEY`. These exports are now marked `@deprecated` (importable wire vocabulary only; removable at the major version that drops 2025-era support), and the typed method surface no longer offers task methods: +`RequestMethod`/`RequestTypeMap`/`ResultTypeMap`/`NotificationTypeMap` exclude `tasks/*` and `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, and `setNotificationHandler()` do not accept them (the +explicit-schema overloads still work for custom interop). The method-keyed result types are narrowed to match: `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`), and likewise `sampling/createMessage` and `elicitation/create` lose their task-result +union members — the runtime result validation uses the same plain schemas, so a task-shaped response body to one of these methods fails as a local `INVALID_RESULT` error where the result schema rejects it rather than parsing into a mis-typed success. Only the behavior is gone: +servers built on this SDK do not advertise the `tasks` capability, and inbound `tasks/*` requests receive a standard `-32601` (method not found) error. There is no migration path for the removed surface; it was always `@experimental`. Task support is planned to return as an opt-in extension plugin per SEP-2663. ### Wire-only protocol members hidden from the public types -The protocol revision 2026-07-28 introduces wire-level bookkeeping that the SDK handles internally and that never needs to reach application code: the `resultType` result discrimination field, the reserved per-request `_meta` envelope keys (`io.modelcontextprotocol/protocolVersion`, `io.modelcontextprotocol/clientInfo`, `io.modelcontextprotocol/clientCapabilities`, `io.modelcontextprotocol/logLevel`), and the multi-round-trip retry fields (`inputResponses`, `requestState`). The public TypeScript surface no longer declares these members: +The protocol revision 2026-07-28 introduces wire-level bookkeeping that the SDK handles internally and that never needs to reach application code: the `resultType` result discrimination field, the reserved per-request `_meta` envelope keys +(`io.modelcontextprotocol/protocolVersion`, `io.modelcontextprotocol/clientInfo`, `io.modelcontextprotocol/clientCapabilities`, `io.modelcontextprotocol/logLevel`), and the multi-round-trip retry fields (`inputResponses`, `requestState`). The public TypeScript surface no longer +declares these members: -- **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, `GetPromptResult`, …, and the `result` member of `JSONRPCResultResponse`). The wire schemas keep parsing it, and the protocol layer consumes it before results reach your code. If you previously read `result.resultType` (it was always `undefined` from conforming 2025-era peers), drop the read — the SDK now owns that field. -- **High-level methods return the named public types.** `client.callTool()` returns `Promise`, `client.listTools()` returns `Promise`, and so on (previously these returned structurally inferred schema types that exposed `resultType?`). Handler return positions are unaffected: results you build keep type-checking, and unknown members still pass through the loose index signature. +- **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, `GetPromptResult`, …, and the `result` member of `JSONRPCResultResponse`). The wire schemas keep parsing it, and the protocol layer consumes it before results reach your code. If you previously + read `result.resultType` (it was always `undefined` from conforming 2025-era peers), drop the read — the SDK now owns that field. +- **High-level methods return the named public types.** `client.callTool()` returns `Promise`, `client.listTools()` returns `Promise`, and so on (previously these returned structurally inferred schema types that exposed `resultType?`). Handler + return positions are unaffected: results you build keep type-checking, and unknown members still pass through the loose index signature. - **The reserved envelope keys and retry fields never appear in a public params/result type.** The `RequestMetaEnvelope` type and the four `*_META_KEY` constants stay exported — they document the wire names and type the context surfacing channel (see below). The protocol layer enforces the same boundary at runtime: -- **Envelope lift.** On inbound requests and notifications, the reserved `io.modelcontextprotocol/*` envelope keys are lifted out of `params._meta` before handlers run, so handler params are byte-equal to the 2025-era shape under 2026-era traffic. For requests the envelope is readable at `ctx.mcpReq.envelope` (typed `Partial` — only the keys the request actually carried are present); for notifications there is no per-message context, so lifted envelope keys are dropped, not surfaced. On requests only, the multi-round-trip retry fields are likewise lifted out of top-level params and surfaced verbatim at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. -- **What this means for 2025-era peers.** The `_meta` side of the lift is invisible to conforming 2025-era traffic: the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too, so a conforming 2025 peer never puts application data under those keys. The retry-field lift is the one collision to know about: 2025-11-25 does not reserve the bare names `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that happens to use them as ordinary top-level params will have them lifted out of the handler's view (still readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`, just no longer in `request.params`). Spec-method requests are unaffected (no 2025 spec method defines params with those names), as are all notifications. -- **Raw-first result discrimination.** The client funnel inspects a response's raw `resultType` before schema validation: `'complete'` is consumed (stripped) and the result parses as the public shape; any other kind (e.g. `input_required`) rejects with a typed local error — `SdkError` with the new code `SdkErrorCode.UnsupportedResultType` and the kind in `error.data.resultType` — instead of being masked into a hollow success by tolerant result schemas. Full multi-round-trip support will replace that error arm. -- **`MessageExtraInfo.classification`** is an optional carrier (`{ era, revision?, envelope? }`) for transports that classify inbound messages at the edge. The wire era itself is connection state (the negotiated protocol version held by the `Client`/`Server` instance); dispatch validates a classified message against that era and treats a mismatch as an entry/routing error (see the next section). - -**Before (v2 alpha):** - -```typescript -const result = await client.callTool({ name: 'echo', arguments: {} }); -// result.resultType was declared as `string | undefined` and always undefined -if (result.resultType === undefined || result.resultType === 'complete') { - console.log(result.content); -} -``` - -**After:** - -```typescript -const result = await client.callTool({ name: 'echo', arguments: {} }); -// resultType is wire-level bookkeeping the SDK consumes; just use the result -console.log(result.content); -``` +- **Envelope lift.** On inbound requests and notifications, the reserved `io.modelcontextprotocol/*` envelope keys are lifted out of `params._meta` before handlers run, so handler params are byte-equal to the 2025-era shape under 2026-era traffic. For requests the envelope is + readable at `ctx.mcpReq.envelope` (typed `Partial` — only the keys the request actually carried are present); for notifications there is no per-message context, so lifted envelope keys are dropped, not surfaced. On requests only, the multi-round-trip retry + fields are likewise lifted out of top-level params and surfaced verbatim at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. +- **What this means for 2025-era peers.** The `_meta` side of the lift is invisible to conforming 2025-era traffic: the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too, so a conforming 2025 peer never puts application data under those keys. The retry-field lift is + the one collision to know about: 2025-11-25 does not reserve the bare names `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that happens to use them as ordinary top-level params will have them lifted out of the handler's view (still readable at + `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`, just no longer in `request.params`). Spec-method requests are unaffected (no 2025 spec method defines params with those names), as are all notifications. +- **Raw-first result discrimination.** The client funnel inspects a response's raw `resultType` before schema validation: `'complete'` is consumed (stripped) and the result parses as the public shape; `'input_required'` is fulfilled by the client's multi-round-trip engine (see + "Multi round-trip requests" below); any other kind rejects with a typed local error — `SdkError` with the new code `SdkErrorCode.UnsupportedResultType` and the kind in `error.data.resultType` — instead of being masked into a hollow success by tolerant result schemas. +- **`MessageExtraInfo.classification`** is an optional carrier (`{ era, revision?, envelope? }`) for transports that classify inbound messages at the edge. The wire era itself is connection state (the negotiated protocol version held by the `Client`/`Server` instance); dispatch + validates a classified message against that era and treats a mismatch as an entry/routing error (see the next section). ### Per-era wire codecs: physical deletions and stricter wire schemas -The wire layer is now split into per-revision codecs inside the (private, bundled) core: one codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The codec is selected by the negotiated protocol version, which is connection state on the `Client`/`Server` instance: the client stores it when its initialize handshake completes, the server stores it when it answers `initialize`, and instances with no negotiated version default to the 2025 era (with the pre-negotiation lifecycle messages routed by method: `initialize`/`notifications/initialized` are 2025-era vocabulary, `server/discover` is 2026-era vocabulary). An edge classification (`MessageExtraInfo.classification`) no longer switches the era per message — it is validated against the instance era, and a mismatch is rejected as an entry/routing error (`-32004 Unsupported protocol version` for requests, a drop plus `onerror` for notifications). Methods deleted by a protocol revision are now PHYSICALLY absent from that era's registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a handler is registered, and sending an era-mismatched spec method (for example `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws a typed local error — `SdkError` with the new code `SdkErrorCode.MethodNotSupportedByProtocolVersion` — before anything reaches the transport. +The wire layer is now split into per-revision codecs inside the (private, bundled) core: one codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The codec is selected by the negotiated protocol version, which is connection state on +the `Client`/`Server` instance: the client stores it when its initialize handshake completes, the server stores it when it answers `initialize`, and instances with no negotiated version default to the 2025 era (with the pre-negotiation lifecycle messages routed by method: +`initialize`/`notifications/initialized` are 2025-era vocabulary, `server/discover` is 2026-era vocabulary). An edge classification (`MessageExtraInfo.classification`) no longer switches the era per message — it is validated against the instance era, and a mismatch is rejected as +an entry/routing error (`-32004 Unsupported protocol version` for requests, a drop plus `onerror` for notifications). Methods deleted by a protocol revision are now PHYSICALLY absent from that era's registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a +handler is registered, and sending an era-mismatched spec method (for example `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws a typed local error — `SdkError` with the new code `SdkErrorCode.MethodNotSupportedByProtocolVersion` — +before anything reaches the transport. Alongside the split, the following deliberate wire-behavior changes ship (each is invisible to conforming peers but observable to direct schema consumers and misbehaving peers): - **`resultType` is no longer modeled by any neutral wire schema.** The base `ResultSchema` (and every result schema derived from it) no longer declares the optional `resultType` member. Consequences: - - `EmptyResultSchema` (strict) now REJECTS `{resultType: ...}` bodies where it previously accepted them. On the protocol path nothing changes for conforming peers: the 2026-era codec consumes the field, and the 2025-era codec strips a foreign `resultType` before validation (tolerate-and-drop — a 2025-era peer that sends it is misbehaving). - - On a 2025-era connection, a response carrying a non-`'complete'` `resultType` is no longer rejected with `UnsupportedResultType`: the field is foreign vocabulary on that era and is stripped before validation (the result then passes or fails validation on its actual content, loudly). On a 2026-era exchange the discrimination is stricter than before: `resultType` is REQUIRED, an absent value is a spec violation surfaced as a typed error, and `input_required` / unknown kinds reject with `UnsupportedResultType` / `InvalidResult`. -- **`CallToolResult.content` and `ToolResultContent.content` are required at the wire boundary.** The `content.default([])` affordance was removed (it could silently convert unrecognized result shapes into hollow `{content: []}` successes). Tool handlers MUST include `content` in their results (the TypeScript surface always required it — `content: []` is fine); a handler result without it is now rejected with `-32602 Invalid tools/call result` instead of being silently defaulted, and a content-less wire result fails the client-side parse loudly. -- **Custom (3-arg) handlers receive `_meta`.** `setRequestHandler(method, {params}, handler)` / `setNotificationHandler(method, {params}, handler)` used to DELETE `params._meta` before validating with your schema. They now pass it through minus the reserved `io.modelcontextprotocol/*` envelope keys (which the protocol layer lifts out), making custom methods consistent with spec methods. If your params schema is strict (rejects unknown keys), add an optional `_meta` member or strip it yourself. -- **`specTypeSchemas` validate the neutral model.** Result entries no longer accept/declare `resultType`; the validators for the 2025-only task message types (`Task`, `TaskStatus`, `GetTask*`, `ListTasks*`, `CancelTask*`, `CreateTaskResult`, `TaskStatusNotification*`, `TaskCreationParams`) and for `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed accordingly). Per-revision wire validators are planned to return as versioned `zod-schemas/` exports. -- **Role aggregate types no longer carry task vocabulary.** `ClientRequest`, `ClientResult`, `ClientNotification`, `ServerRequest`, `ServerResult`, and `ServerNotification` (and their union schemas) are now the neutral message sets; the task members moved into the internal 2025-era wire module. The individual `Task*` types remain importable (deprecated) exactly as before. -- **Value guards are consumer-side checks, not wire validators.** `isCallToolResult` and friends now validate the neutral shapes; a raw wire object carrying `resultType` still passes them through the loose index signature. Validate raw wire traffic with a transport-level parse, not the guards. + - `EmptyResultSchema` (strict) now REJECTS `{resultType: ...}` bodies where it previously accepted them. On the protocol path nothing changes for conforming peers: the 2026-era codec consumes the field, and the 2025-era codec strips a foreign `resultType` before validation + (tolerate-and-drop — a 2025-era peer that sends it is misbehaving). + - On a 2025-era connection, a response carrying a non-`'complete'` `resultType` is no longer rejected with `UnsupportedResultType`: the field is foreign vocabulary on that era and is stripped before validation (the result then passes or fails validation on its actual content, + loudly). On a 2026-era exchange the discrimination is stricter than before: `resultType` is REQUIRED, an absent value is a spec violation surfaced as a typed error, and `input_required` / unknown kinds reject with `UnsupportedResultType` / `InvalidResult`. +- **`CallToolResult.content` and `ToolResultContent.content` are required at the wire boundary.** The `content.default([])` affordance was removed (it could silently convert unrecognized result shapes into hollow `{content: []}` successes). Tool handlers MUST include `content` in + their results (the TypeScript surface always required it — `content: []` is fine); a handler result without it is now rejected with `-32602 Invalid tools/call result` instead of being silently defaulted, and a content-less wire result fails the client-side parse loudly. +- **Custom (3-arg) handlers receive `_meta`.** `setRequestHandler(method, {params}, handler)` / `setNotificationHandler(method, {params}, handler)` used to DELETE `params._meta` before validating with your schema. They now pass it through minus the reserved + `io.modelcontextprotocol/*` envelope keys (which the protocol layer lifts out), making custom methods consistent with spec methods. If your params schema is strict (rejects unknown keys), add an optional `_meta` member or strip it yourself. +- **`specTypeSchemas` validate the neutral model.** Result entries no longer accept/declare `resultType`; the validators for the 2025-only task message types (`Task`, `TaskStatus`, `GetTask*`, `ListTasks*`, `CancelTask*`, `CreateTaskResult`, `TaskStatusNotification*`, + `TaskCreationParams`) and for `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed accordingly). Per-revision wire validators are planned to return as versioned `zod-schemas/` exports. +- **Role aggregate types no longer carry task vocabulary.** `ClientRequest`, `ClientResult`, `ClientNotification`, `ServerRequest`, `ServerResult`, and `ServerNotification` (and their union schemas) are now the neutral message sets; the task members moved into the internal + 2025-era wire module. The individual `Task*` types remain importable (deprecated) exactly as before. +- **Value guards are consumer-side checks, not wire validators.** `isCallToolResult` and friends now validate the neutral shapes; a raw wire object carrying `resultType` still passes them through the loose index signature. Validate raw wire traffic with a transport-level parse, + not the guards. **Before:** @@ -975,11 +985,7 @@ server.setRequestHandler('tools/call', async () => { }); // Custom handlers receive _meta minus the reserved envelope keys: -protocol.setRequestHandler( - 'acme/op', - { params: z.strictObject({ x: z.number(), _meta: z.record(z.string(), z.unknown()).optional() }) }, - async params => ({}) -); +protocol.setRequestHandler('acme/op', { params: z.strictObject({ x: z.number(), _meta: z.record(z.string(), z.unknown()).optional() }) }, async params => ({})); ``` ## Enhancements @@ -993,10 +999,7 @@ import { Client } from '@modelcontextprotocol/client'; // Auto-negotiate: try the 2026-07-28 draft revision, fall back to the 2025 // handshake automatically when the server is a 2025-era deployment. -const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' } } -); +const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(transport); client.getNegotiatedProtocolVersion(); // e.g. '2026-07-28' or '2025-11-25' @@ -1005,14 +1008,15 @@ client.getNegotiatedProtocolVersion(); // e.g. '2026-07-28' or '2025-11-25' How the modes behave: - **absent / `mode: 'legacy'`** (default): today's behavior, unchanged. No probe, no new headers. -- **`mode: 'auto'`**: `connect()` first sends a single `server/discover` probe. A modern server answers it and no `initialize` is sent; a 2025-era server rejects it (deployed servers answer fast, e.g. `-32601` or a `400`), and the client falls back to the plain legacy - handshake **on the same connection** — byte-equivalent to a 2025 client, including the `initialize` body version and with zero 2026 headers. The probe costs one round trip against an old server and nothing else. +- **`mode: 'auto'`**: `connect()` first sends a single `server/discover` probe. A modern server answers it and no `initialize` is sent; a 2025-era server rejects it (deployed servers answer fast, e.g. `-32601` or a `400`), and the client falls back to the plain legacy handshake + **on the same connection** — byte-equivalent to a 2025 client, including the `initialize` body version and with zero 2026 headers. The probe costs one round trip against an old server and nothing else. - **`mode: { pin: '2026-07-28' }`**: modern era at exactly that revision. No fallback — if the server does not offer the pinned version, `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). -Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era — provided the supported-versions list still contains a 2025-era -revision; with a modern-only list there is nothing to fall back to and `connect()` rejects with the typed negotiation error instead — while a network outage rejects with a typed connect error (`SdkError` with `EraNegotiationFailed`). A probe timeout is transport-aware, following the specification's backward-compatibility rules: on **stdio**, a server that does not answer the probe within the timeout is treated as a legacy server (some legacy servers never respond to unknown -pre-`initialize` requests at all) and the client falls back to `initialize` on the same stream; on **HTTP**, where a deployed server answers and silence means an outage, the timeout rejects with a typed `RequestTimeout` error — a dead HTTP server is never misreported as a -legacy server. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers and the legacy handshake sends none of them. +Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era — provided the supported-versions list still contains a 2025-era revision; with a +modern-only list there is nothing to fall back to and `connect()` rejects with the typed negotiation error instead — while a network outage rejects with a typed connect error (`SdkError` with `EraNegotiationFailed`). A probe timeout is transport-aware, following the +specification's backward-compatibility rules: on **stdio**, a server that does not answer the probe within the timeout is treated as a legacy server (some legacy servers never respond to unknown pre-`initialize` requests at all) and the client falls back to `initialize` on the +same stream; on **HTTP**, where a deployed server answers and silence means an outage, the timeout rejects with a typed `RequestTimeout` error — a dead HTTP server is never misreported as a legacy server. One browser-specific exception: an opaque CORS/preflight `TypeError` during +the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers and the legacy handshake sends none of them. Probe policy is configured under `versionNegotiation.probe`: @@ -1026,9 +1030,9 @@ versionNegotiation: { ``` On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize` -handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and -other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation probe -already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. +handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and other +long-lived connections) is the `serveStdio` entry point described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation +probe already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` @@ -1051,15 +1055,14 @@ const handler = createMcpHandler(ctx => { How the `legacy` option behaves: -- **omitted / `legacy: 'stateless'`** (the default) — 2025-era (non-envelope) traffic is served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only - `sessionIdGenerator: undefined`. Because this serving is per-request and stateless, GET and DELETE (2025 session operations) are answered `405` / `Method not allowed.`, exactly like the canonical stateless example. The exported `legacyStatelessFallback(factory)` is the - same serving as a standalone fetch-shaped handler for hand-wired compositions. -- **`legacy: 'reject'`** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no - 2025 serving in this mode.** +- **omitted / `legacy: 'stateless'`** (the default) — 2025-era (non-envelope) traffic is served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only `sessionIdGenerator: undefined`. + Because this serving is per-request and stateless, GET and DELETE (2025 session operations) are answered `405` / `Method not allowed.`, exactly like the canonical stateless example. The exported `legacyStatelessFallback(factory)` is the same serving as a standalone fetch-shaped + handler for hand-wired compositions. +- **`legacy: 'reject'`** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no 2025 + serving in this mode.** -> **If you have an existing sessionful 1.x Streamable HTTP setup** (a `StreamableHTTPServerTransport` wiring with session IDs that your deployed 2025-era clients depend on), keep that handler serving 2025 traffic and route it in front of a strict (`legacy: 'reject'`) -> entry with the exported `isLegacyRequest(request)` predicate. The predicate is the entry's own classification step (the same code `createMcpHandler` runs to decide a request is not on the modern path), so a composition that branches on it can never disagree with the -> entry: +> **If you have an existing sessionful 1.x Streamable HTTP setup** (a `StreamableHTTPServerTransport` wiring with session IDs that your deployed 2025-era clients depend on), keep that handler serving 2025 traffic and route it in front of a strict (`legacy: 'reject'`) entry with +> the exported `isLegacyRequest(request)` predicate. The predicate is the entry's own classification step (the same code `createMcpHandler` runs to decide a request is not on the modern path), so a composition that branches on it can never disagree with the entry: > > ```typescript > // An existing sessionful 1.x streamable HTTP wiring keeps serving 2025 clients, routed in front of a strict entry. @@ -1077,22 +1080,22 @@ How the `legacy` option behaves: > }; > ``` > -> `isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, and non-JSON bodies). It returns `false` for everything the -> modern path answers — including a request carrying a **malformed** modern claim, which the modern path rejects with `-32602` — so route `false` traffic to the modern handler, never to your legacy handler. The predicate classifies a clone, so the request body stays -> readable for whichever handler you route to (pass an already-parsed body as the second argument if the stream has been consumed). +> `isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, and non-JSON bodies). It returns `false` for everything the modern path +> answers — including a request carrying a **malformed** modern claim, which the modern path rejects with `-32602` — so route `false` traffic to the modern handler, never to your legacy handler. The predicate classifies a clone, so the request body stays readable for whichever +> handler you route to (pass an already-parsed body as the second argument if the stream has been consumed). -The optional `responseMode` controls how modern request exchanges are answered: `'auto'` (default) returns a single JSON body and lazily upgrades to an SSE stream when the handler emits a related message before its result; `'sse'` always streams; **`'json'` never streams -and DROPS mid-call notifications** (progress, logging, and any other related message emitted before the result) — only the terminal result is delivered. Subscription (listen-class) streams are always served over SSE regardless of the setting. `onerror` receives -out-of-band errors and rejected requests for logging. +The optional `responseMode` controls how modern request exchanges are answered: `'auto'` (default) returns a single JSON body and lazily upgrades to an SSE stream when the handler emits a related message before its result; `'sse'` always streams; **`'json'` never streams and +DROPS mid-call notifications** (progress, logging, and any other related message emitted before the result) — only the terminal result is delivered. Subscription (listen-class) streams are always served over SSE regardless of the setting. `onerror` receives out-of-band errors and +rejected requests for logging. -The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` / attached as `req.auth` on the Node face is forwarded to handlers as-is and never derived from -request headers. Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed -around (`const { fetch } = handler`). +The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` / attached as `req.auth` on the Node face is forwarded to handlers as-is and never derived from request +headers. Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around +(`const { fetch } = handler`). ### Serving the 2026-07-28 draft revision on stdio: `serveStdio` -The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to -that connection and serves only that era. +The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to that +connection and serves only that era. ```typescript import { McpServer } from '@modelcontextprotocol/server'; @@ -1107,28 +1110,27 @@ serveStdio(() => { How the connection's era is decided: -- A plain 2025 client opens with the `initialize` handshake (or any request without the per-request `_meta` envelope): the connection is pinned to a 2025-era instance and served exactly as a hand-wired stdio server serves it today. Pass `legacy: 'reject'` to refuse - 2025-era openings instead — they are answered with the unsupported-protocol-version error naming the supported modern revisions, and there is no silent 2025 serving. +- A plain 2025 client opens with the `initialize` handshake (or any request without the per-request `_meta` envelope): the connection is pinned to a 2025-era instance and served exactly as a hand-wired stdio server serves it today. Pass `legacy: 'reject'` to refuse 2025-era + openings instead — they are answered with the unsupported-protocol-version error naming the supported modern revisions, and there is no silent 2025 serving. - A 2026-capable client opens with requests carrying the per-request `_meta` envelope: the connection is pinned to a 2026-era instance. -- A `server/discover` probe is answered (from an instance built with your factory, so the advertisement reflects your real server definition) without pinning the connection: the client either continues with enveloped modern requests — pinning the connection to the 2026 - era — or falls back to `initialize` when it shares no modern revision with the advertisement, in which case the probe instance is discarded and a fresh 2025-era instance serves the handshake. Once the modern era is pinned, a later `initialize` is rejected with the +- A `server/discover` probe is answered (from an instance built with your factory, so the advertisement reflects your real server definition) without pinning the connection: the client either continues with enveloped modern requests — pinning the connection to the 2026 era — or + falls back to `initialize` when it shares no modern revision with the advertisement, in which case the probe instance is discarded and a fresh 2025-era instance serves the handshake. Once the modern era is pinned, a later `initialize` is rejected with the unsupported-protocol-version error naming the supported revisions. -Because the entry may construct an instance for a probe that is later discarded (and `createMcpHandler` constructs one per request), factories should be cheap and side-effect-free. Bring your own transport with the `transport` option (for example a -`StdioServerTransport` over a Unix domain socket or TCP stream); by default the entry serves the current process's stdio. The returned handle's `close()` tears down the pinned instance and the transport. +Because the entry may construct an instance for a probe that is later discarded (and `createMcpHandler` constructs one per request), factories should be cheap and side-effect-free. Bring your own transport with the `transport` option (for example a `StdioServerTransport` over a +Unix domain socket or TCP stream); by default the entry serves the current process's stdio. The returned handle's `close()` tears down the pinned instance and the transport. -Directionality follows the connection's era: the 2026-07-28 revision has no server→client JSON-RPC request channel, so handlers serving a 2026-pinned connection cannot emit `sampling`/`elicitation`/`roots` wire requests (they fail locally with a typed error), while a -2025-pinned connection keeps today's behavior. Symmetrically, a client whose connection negotiated a modern era drops inbound JSON-RPC requests instead of answering them. +Directionality follows the connection's era: the 2026-07-28 revision has no server→client JSON-RPC request channel, so handlers serving a 2026-pinned connection cannot emit `sampling`/`elicitation`/`roots` wire requests (they fail locally with a typed error), while a 2025-pinned +connection keeps today's behavior. Symmetrically, a client whose connection negotiated a modern era drops inbound JSON-RPC requests instead of answering them. -**The v1 stdio pattern keeps working and stays 2025-only.** A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` — the way every v1 stdio server is written — still works and serves only the 2025-era protocol it was written for: upgrading -the SDK changes nothing about what it puts on the wire, and no per-instance option turns such a server into a 2026-era server. Serving the 2026-07-28 revision (or both eras) on stdio always goes through `serveStdio`. To migrate an existing v1 stdio server, move its -construction into the factory: replace `await server.connect(new StdioServerTransport())` with `serveStdio(() => buildServer())`, registering tools/resources/prompts inside the factory as before — and pass `{ legacy: 'reject' }` if 2025-era clients should be refused -instead of served. +**The v1 stdio pattern keeps working and stays 2025-only.** A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` — the way every v1 stdio server is written — still works and serves only the 2025-era protocol it was written for: upgrading the SDK +changes nothing about what it puts on the wire, and no per-instance option turns such a server into a 2026-era server. Serving the 2026-07-28 revision (or both eras) on stdio always goes through `serveStdio`. To migrate an existing v1 stdio server, move its construction into the +factory: replace `await server.connect(new StdioServerTransport())` with `serveStdio(() => buildServer())`, registering tools/resources/prompts inside the factory as before — and pass `{ legacy: 'reject' }` if 2025-era clients should be refused instead of served. ### Cache fields and cache hints for cacheable 2026-07-28 results -The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`). When serving that revision, the SDK now always emits both fields, -defaulting to `ttlMs: 0` and `cacheScope: 'private'` — the most conservative policy, equivalent to "do not cache". To advertise a real cache policy: +The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`). When serving that revision, the SDK now always emits both fields, defaulting to +`ttlMs: 0` and `cacheScope: 'private'` — the most conservative policy, equivalent to "do not cache". To advertise a real cache policy: ```typescript const server = new McpServer( @@ -1147,23 +1149,65 @@ server.registerResource('config', 'config://app', { cacheHint: { ttlMs: 5_000 } ``` Resolution is per field, most specific author first: for each of `ttlMs` and `cacheScope`, a value returned by the handler itself (when valid) wins over the per-resource `cacheHint`, which wins over `ServerOptions.cacheHints[operation]`, which wins over the default — so a -per-resource hint that sets only one field never suppresses the other field configured at the operation level. Configured hints are validated when they are configured — an invalid `ttlMs` (negative or non-integer) or `cacheScope` throws a `RangeError`. Responses on -2025-era connections never carry these fields, with or without configuration. +per-resource hint that sets only one field never suppresses the other field configured at the operation level. Configured hints are validated when they are configured — an invalid `ttlMs` (negative or non-integer) or `cacheScope` throws a `RangeError`. Responses on 2025-era +connections never carry these fields, with or without configuration. + +### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver + +The 2026-07-28 revision removes the server→client JSON-RPC request channel: servers obtain client input (elicitation, sampling, roots) **in-band**, by answering `tools/call`, `prompts/get`, or `resources/read` with an `input_required` result that embeds the requests, and the +client retries the original call with the responses. The SDK ships both halves: + +**Server side — return `inputRequired(...)` instead of pushing requests.** A handler for one of the three multi-round-trip methods requests input by returning the value built by `inputRequired()` (with the per-kind constructors `inputRequired.elicit`, `inputRequired.elicitUrl`, +`inputRequired.createMessage`, `inputRequired.listRoots`), and reads the responses on re-entry from `ctx.mcpReq.inputResponses` (the `acceptedContent()` helper reads an accepted form elicitation). Hand-built `resultType: 'input_required'` literals are equally legal. + +```typescript +const confirmSchema = { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } as const; + +server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: confirmSchema }) } + }); + } + return { content: [{ type: 'text', text: `deployed to ${env}` }] }; +}); +``` + +The in-band return is only legal toward 2026-07-28 requests. **A handler that serves both eras branches on the served era**: 2025-era handlers keep using the push-style APIs (`ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`, instance-level +`createMessage()`/`elicitInput()`/`listRoots()`), and modern handlers return `inputRequired(...)` — an `input_required` return on a 2025-era request fails as a server-side internal error rather than reaching the wire mis-typed. URL elicitation on the 2026-07-28 era is expressed +with `inputRequired.elicitUrl(...)` (correlation across retries belongs in `requestState`); throwing the 1.x `UrlElicitationRequiredError` on a 2026-era request fails loudly with a clear steer to that constructor (it is not converted), while 2025-era serving keeps today's +`-32042` behavior exactly. + +On 2026-era requests the push-style APIs (`ctx.mcpReq.send` of server→client requests, `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`, and the instance-level `server.createMessage()`/`elicitInput()`/`listRoots()`/`ping()` on modern-bound instances) fail with a typed local +error before anything reaches the wire; in a tool handler the error surfaces to the caller as an `isError` result whose text steers to returning `inputRequired(...)`. Their behavior toward 2025-era requests is unchanged. The error surface differs per family exactly as it always +has: only `tools/call` has a catch-all that wraps handler failures into `isError` results — errors thrown by `prompts/get` and `resources/read` handlers (including the loud failures of the seam guards) surface as JSON-RPC errors. + +**`requestState` is untrusted input — protect it yourself.** `inputRequired({ requestState })` lets a server round-trip opaque state through the client instead of holding it in memory. The SDK treats it as an opaque string end to end: the client echoes it back byte-exact and +never parses it, and the server sees the echoed value raw at `ctx.mcpReq.requestState`. The specification's requirement is the consumer's obligation: the value comes back as **attacker-controlled input**, so if it influences authorization, resource access, or business logic you +MUST integrity-protect it when minting it (for example HMAC or AEAD over the payload, bound to the principal, the originating method/parameters, and an expiry) and MUST reject state that fails verification on re-entry. The SDK does not provide or apply any sealing of its own, +but it does provide the place to put your verification: configure `ServerOptions.requestState.verify`, and the seam runs it before the handler whenever `requestState` is present — a thrown rejection answers the client with a frozen `-32602` (above the tool funnel, so it is a +real JSON-RPC error rather than an `isError` result). See `examples/server/src/multiRoundTrip.ts` for a worked HMAC example. + +**Client side — auto-fulfilment by default.** When a call to `tools/call`, `prompts/get`, or `resources/read` on a 2026-07-28 connection answers `input_required`, the client fulfils the embedded requests through the same handlers registered with +`setRequestHandler('elicitation/create' | 'sampling/createMessage' | 'roots/list', …)` and retries the original request (fresh request id, `inputResponses`, byte-exact `requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). `client.callTool()` and its siblings +keep returning their plain result types — the interactive rounds happen inside the call, and a registered handler written for the 2025 flow keeps working unchanged. Configure or opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`), drive the flow manually per call +with the `allowInputRequired: true` request option plus the `withInputRequired()` schema wrapper, and expect the typed `InputRequiredRoundsExceeded` error when the round cap is exhausted. 2025-era connections are unaffected (the legacy wire has no `input_required` vocabulary). ### Typed `-32003` missing-client-capability error -`MissingRequiredClientCapabilityError` is the typed error class for the 2026-07-28 `-32003` protocol error: processing a request requires a capability the client did not declare in the request's `clientCapabilities`. Its `data.requiredCapabilities` lists the missing -capabilities, and `ProtocolError.fromError` recognizes the code/data shape (recognize peers' errors by their code and `error.data`, not by `instanceof`). When the HTTP entry refuses such a request, the response uses HTTP status `400` as the specification requires. +`MissingRequiredClientCapabilityError` is the typed error class for the 2026-07-28 `-32003` protocol error: processing a request requires a capability the client did not declare in the request's `clientCapabilities`. Its `data.requiredCapabilities` lists the missing capabilities, +and `ProtocolError.fromError` recognizes the code/data shape (recognize peers' errors by their code and `error.data`, not by `instanceof`). When the HTTP entry refuses such a request, the response uses HTTP status `400` as the specification requires. The multi-round-trip seam +answers with the same error when a handler embeds an input request (for example an elicitation) that the request's declared client capabilities do not cover. ### Client identity accessors deprecated in favor of per-request context -`Server.getClientCapabilities()`, `Server.getClientVersion()` and `Server.getNegotiatedProtocolVersion()` are deprecated (they remain functional). On 2026-07-28 requests the client's identity travels with each request in the validated `_meta` envelope and is available to -handlers as `ctx.mcpReq.envelope`; instances serving that revision through `createMcpHandler` are backfilled per request, so existing code that calls the accessors keeps working on both eras. On 2025-era connections the accessors keep returning the `initialize`-scoped -values, as before. +`Server.getClientCapabilities()`, `Server.getClientVersion()` and `Server.getNegotiatedProtocolVersion()` are deprecated (they remain functional). On 2026-07-28 requests the client's identity travels with each request in the validated `_meta` envelope and is available to handlers +as `ctx.mcpReq.envelope`; instances serving that revision through `createMcpHandler` are backfilled per request, so existing code that calls the accessors keeps working on both eras. On 2025-era connections the accessors keep returning the `initialize`-scoped values, as before. -On a connection pinned to the 2026-07-28 era by `serveStdio` the identity accessors are **not** backfilled: the modern era carries client identity per request, so connection-scoped identity has nothing stable to report there. -`getClientCapabilities()` and `getClientVersion()` return `undefined` (no `initialize` handshake ever ran on such a connection) and handlers read the per-request identity from `ctx.mcpReq.envelope`. `getNegotiatedProtocolVersion()` reports the pinned revision -(`2026-07-28`) — the entry era-marks the instance when it binds it, so the accessor reports the same value as on instances serving that revision through `createMcpHandler`. On 2025-pinned connections the accessors keep their `initialize`-scoped semantics, as before. +On a connection pinned to the 2026-07-28 era by `serveStdio` the identity accessors are **not** backfilled: the modern era carries client identity per request, so connection-scoped identity has nothing stable to report there. `getClientCapabilities()` and `getClientVersion()` +return `undefined` (no `initialize` handshake ever ran on such a connection) and handlers read the per-request identity from `ctx.mcpReq.envelope`. `getNegotiatedProtocolVersion()` reports the pinned revision (`2026-07-28`) — the entry era-marks the instance when it binds it, so +the accessor reports the same value as on instances serving that revision through `createMcpHandler`. On 2025-pinned connections the accessors keep their `initialize`-scoped semantics, as before. ### Origin validation middleware and default arming @@ -1176,10 +1220,10 @@ const app = createMcpExpressApp(); // localhost bind: Host AND Origin validation const appCustom = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); ``` -Requests without an `Origin` header pass unchanged (MCP clients outside a browser do not send one), so non-browser traffic is unaffected. A present `Origin` whose hostname is not allowed — or that cannot be parsed, including the opaque `null` origin — is rejected with -`403` (deny on failure). For a localhost-bound factory app there is no switch that turns Origin validation off: passing an explicit `allowedOrigins` list replaces the default localhost allowlist (use it to allow additional origins, such as a deployed web frontend), and -validation stays armed. The framework-agnostic helpers (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) live in -`@modelcontextprotocol/server` for bare web-standard mounts, and `@modelcontextprotocol/node` now ships request guards (`hostHeaderValidation`, `originValidation` and their `localhost*` variants) for plain `node:http` servers, which previously had no validation helpers. +Requests without an `Origin` header pass unchanged (MCP clients outside a browser do not send one), so non-browser traffic is unaffected. A present `Origin` whose hostname is not allowed — or that cannot be parsed, including the opaque `null` origin — is rejected with `403` (deny +on failure). For a localhost-bound factory app there is no switch that turns Origin validation off: passing an explicit `allowedOrigins` list replaces the default localhost allowlist (use it to allow additional origins, such as a deployed web frontend), and validation stays +armed. The framework-agnostic helpers (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) live in `@modelcontextprotocol/server` for bare web-standard mounts, and `@modelcontextprotocol/node` now ships request guards (`hostHeaderValidation`, +`originValidation` and their `localhost*` variants) for plain `node:http` servers, which previously had no validation helpers. ### Automatic JSON Schema validator selection by runtime @@ -1270,9 +1314,9 @@ The following APIs are unchanged between v1 and v2 (only the import paths change - All Zod schemas and type definitions from `types.ts` (except the aliases listed above) - Tool, prompt, and resource callback return types -**Session-ID mismatch responses**: when session management is enabled and a request carries an `Mcp-Session-Id` header that doesn't match the active session, the Streamable HTTP server transport responds `404 Not Found` with a JSON-RPC error body using code `-32001` and -message `Session not found` — unchanged from v1. Note that this use of `-32001` is an SDK convention, not a spec-assigned error code, and it is expected to be re-derived as error handling for the 2026 protocol revision (`2026-07-28`) is adopted. Avoid hard-coding the -`-32001` code in client logic; key off the HTTP `404` status instead. +**Session-ID mismatch responses**: when session management is enabled and a request carries an `Mcp-Session-Id` header that doesn't match the active session, the Streamable HTTP server transport responds `404 Not Found` with a JSON-RPC error body using code `-32001` and message +`Session not found` — unchanged from v1. Note that this use of `-32001` is an SDK convention, not a spec-assigned error code, and it is expected to be re-derived as error handling for the 2026 protocol revision (`2026-07-28`) is adopted. Avoid hard-coding the `-32001` code in +client logic; key off the HTTP `404` status instead. ## Using an LLM to migrate your code diff --git a/examples/client/README.md b/examples/client/README.md index 46f7c82c9c..0879b3b6c0 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -35,6 +35,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md | OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | | Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | | URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | +| Multi-round-trip client (2026-07-28) | Calls a write-once tool twice: default auto-fulfilment, then manual mode. | [`src/multiRoundTripClient.ts`](src/multiRoundTripClient.ts) | ## URL elicitation example (server + client) diff --git a/examples/client/src/multiRoundTripClient.ts b/examples/client/src/multiRoundTripClient.ts new file mode 100644 index 0000000000..68068806a1 --- /dev/null +++ b/examples/client/src/multiRoundTripClient.ts @@ -0,0 +1,124 @@ +/** + * Drives the multi-round-trip server example + * (`examples/server/src/multiRoundTrip.ts`) two ways on a 2026-07-28 + * connection: + * + * 1. **auto-fulfilment** (the default) — the same `elicitation/create` + * handler the client would register for the 2025-era flow fulfils the + * embedded form and URL elicitations, and the SDK retries the original + * `tools/call` for you. `client.callTool()` returns a plain + * `CallToolResult`; + * 2. **manual mode** — `inputRequired: { autoFulfill: false }` plus per-call + * `allowInputRequired: true`: the input-required value is handed back, and + * the example collects responses, echoes `requestState`, and retries + * itself. + * + * Start the server first, then: + * + * tsx examples/client/src/multiRoundTripClient.ts + * + * (Attaching the per-request `_meta` envelope explicitly is a stop-gap; + * automatic envelope emission for every request is a client-side follow-up.) + */ +import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/client'; +import { + Client, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + isInputRequiredResult, + PROTOCOL_VERSION_META_KEY, + StreamableHTTPClientTransport +} from '@modelcontextprotocol/client'; + +const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/'; +const CLIENT_INFO = { name: 'mrtr-example-client', version: '1.0.0' }; + +// Per-request envelope every 2026-era request carries on the wire. The +// declared client capabilities are what the server's −32003 check reads. +function envelope(negotiated: string): Record { + return { + [PROTOCOL_VERSION_META_KEY]: negotiated, + [CLIENT_INFO_META_KEY]: CLIENT_INFO, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {}, url: {} } } + }; +} + +async function autoFulfilLeg(): Promise { + console.log('--- auto-fulfilment (the default) ---'); + const client = new Client(CLIENT_INFO, { + versionNegotiation: { mode: 'auto' }, + capabilities: { elicitation: { form: {}, url: {} } } + }); + // The SAME handler a 2025-flow client registers: the auto-fulfilment + // engine dispatches embedded form and URL elicitations through it. + client.setRequestHandler('elicitation/create', async request => { + const params = request.params as { mode?: string; message: string; url?: string }; + if (params.mode === 'url') { + console.log(`[client] (auto) url elicitation: ${params.message} → ${params.url}`); + return { action: 'accept' }; + } + console.log(`[client] (auto) form elicitation: ${params.message}`); + return { action: 'accept', content: { confirm: true } }; + }); + + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const negotiated = client.getNegotiatedProtocolVersion()!; + console.log('negotiated protocol version:', negotiated); + + // callTool returns a plain CallToolResult — the interactive rounds happen + // inside the call. + const result = await client.request({ + method: 'tools/call', + params: { name: 'deploy', arguments: { env: 'prod' }, _meta: envelope(negotiated) } + }); + console.log('deploy result:', JSON.stringify(result.content)); + await client.close(); +} + +async function manualLeg(): Promise { + console.log('--- manual mode (autoFulfill: false + allowInputRequired) ---'); + const client = new Client(CLIENT_INFO, { + versionNegotiation: { mode: 'auto' }, + capabilities: { elicitation: { form: {}, url: {} } }, + inputRequired: { autoFulfill: false } + }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const negotiated = client.getNegotiatedProtocolVersion()!; + + let inputResponses: Record | undefined; + let requestState: string | undefined; + for (let round = 0; round < 10; round++) { + // allowInputRequired: true → the call resolves with either the + // complete CallToolResult or the input-required value (use + // `withInputRequired(schema)` on the explicit-schema path to type + // both outcomes; here the method-keyed path is used for brevity). + const value = (await client.request( + { + method: 'tools/call', + params: { + name: 'deploy', + arguments: { env: 'staging' }, + _meta: envelope(negotiated), + ...(inputResponses && { inputResponses }), + ...(requestState && { requestState }) + } + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + if (!isInputRequiredResult(value)) { + console.log('deploy result:', JSON.stringify(value.content)); + break; + } + // Collect responses and echo requestState byte-exact. + console.log(`[client] (manual) round ${round + 1}: server asked for ${Object.keys(value.inputRequests ?? {}).join(', ')}`); + inputResponses = {}; + for (const [key, entry] of Object.entries(value.inputRequests ?? {})) { + inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {}; + } + requestState = value.requestState; + } + await client.close(); +} + +await autoFulfilLeg(); +await manualLeg(); diff --git a/examples/server/README.md b/examples/server/README.md index a71e63a7d5..bce265104a 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -38,6 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts | Sampling server | Demonstrates server-initiated sampling requests. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | | Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | | SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | +| Multi-round-trip server (2026-07-28) | Write-once tool that returns `inputRequired(...)` (form + URL elicitation, requestState echo) via `createMcpHandler`. | [`src/multiRoundTrip.ts`](src/multiRoundTrip.ts) | ## OAuth demo flags (Streamable HTTP server) diff --git a/examples/server/src/multiRoundTrip.ts b/examples/server/src/multiRoundTrip.ts new file mode 100644 index 0000000000..51abba4eb2 --- /dev/null +++ b/examples/server/src/multiRoundTrip.ts @@ -0,0 +1,135 @@ +/** + * A write-once tool served via `createMcpHandler` that requests client input + * with multi round-trip results (protocol revision 2026-07-28). + * + * The `deploy` tool returns `inputRequired(...)` instead of pushing a + * server→client request: a form-mode elicitation for confirmation, then a + * URL-mode elicitation for sign-in via `inputRequired.elicitUrl(...)`. The + * step the tool is waiting for is carried in `requestState`, which the SDK + * round-trips opaquely (echoed byte-exact by the client; the server reads it + * raw at `ctx.mcpReq.requestState`). + * + * `requestState` round-trips through the client and is therefore + * attacker-controlled input on re-entry. A real server MUST integrity-protect + * it (e.g. HMAC or AEAD): this example mints `body.hmac` with a per-process + * key and rejects tampered state via the {@linkcode ServerOptions.requestState} + * `verify` hook, which answers a wire-level `-32602` Invalid Params error. + * + * Run with: + * + * tsx examples/server/src/multiRoundTrip.ts + * + * and point the paired client example at it: + * + * tsx examples/client/src/multiRoundTripClient.ts + */ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; + +import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server'; +import { acceptedContent, createMcpHandler, inputRequired, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] }; + +// Per-process integrity key for requestState. The 2026-07-28 path serves every +// request from a fresh server instance — the state itself is the only thing +// that survives between rounds — so the key is process-local. +const STATE_KEY = randomBytes(32); + +type DeployState = { step: 'confirm' | 'signed-in'; env: string }; + +function mintState(payload: DeployState): string { + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${body}.${createHmac('sha256', STATE_KEY).update(body).digest('base64url')}`; +} + +function verifyState(state: string): void { + const dot = state.lastIndexOf('.'); + const body = dot > 0 ? state.slice(0, dot) : ''; + const expected = createHmac('sha256', STATE_KEY).update(body).digest(); + const provided = Buffer.from(state.slice(dot + 1), 'base64url'); + if (dot <= 0 || provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + throw new Error('requestState failed integrity verification'); + } +} + +function readState(ctx: { mcpReq: { requestState?: string } }): DeployState | undefined { + // The seam-level verify hook has already proven integrity by the time the + // handler runs; this only re-reads the body. + const state = ctx.mcpReq.requestState; + return state === undefined + ? undefined + : (JSON.parse(Buffer.from(state.slice(0, state.lastIndexOf('.')), 'base64url').toString()) as DeployState); +} + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'mrtr-example-server', version: '1.0.0' }, + { capabilities: { tools: {} }, requestState: { verify: verifyState } } + ); + + server.registerTool( + 'deploy', + { + title: 'Deploy (write-once)', + description: 'Deploys to the named environment after a confirmation and a sign-in.', + inputSchema: z.object({ env: z.string() }) + }, + async ({ env }, ctx): Promise => { + // The handler reads the SAME context fields on every entry; what + // changes between rounds is which input responses have arrived and + // what (verified) `requestState` was echoed back. + const state = readState(ctx); + const step = state?.step ?? 'confirm'; + console.error(`[server] tools/call deploy(${env}) step=${step}`); + + if (step === 'confirm') { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) + }, + // The next entry stays at the 'confirm' step until the + // user actually accepts. + requestState: mintState({ step: 'confirm', env }) + }); + } + // Move to the URL-mode sign-in step. URL elicitation rides + // the multi-round-trip flow on this revision — the throw-style + // UrlElicitationRequiredError of earlier revisions is not + // available toward 2026-07-28 requests. + return inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ + message: 'Sign in to continue', + url: `https://example.com/auth?env=${env}` + }) + }, + requestState: mintState({ step: 'signed-in', env }) + }); + } + + // step === 'signed-in': the URL-mode elicitation completed out of + // band — verify the auth response actually arrived. + const auth = ctx.mcpReq.inputResponses?.['auth'] as { action?: string } | undefined; + if (auth?.action !== 'accept') { + return { isError: true, content: [{ type: 'text', text: 'auth response missing or declined' }] }; + } + return { content: [{ type: 'text', text: `deployed to ${state?.env ?? env}` }] }; + } + ); + + return server; +} + +// Host with the per-request HTTP entry on its default posture (2026-07-28 +// served per request; 2025-era traffic served stateless from the same +// factory). +const handler = createMcpHandler(() => buildServer()); +const port = Number(process.env.PORT ?? '3000'); + +createServer((req, res) => void handler.node(req, res)).listen(port, () => { + console.error(`multi-round-trip example server listening on http://localhost:${port}/`); +}); diff --git a/packages/core/src/shared/clientCapabilityRequirements.ts b/packages/core/src/shared/clientCapabilityRequirements.ts index 19c5b1a310..4f8fa65619 100644 --- a/packages/core/src/shared/clientCapabilityRequirements.ts +++ b/packages/core/src/shared/clientCapabilityRequirements.ts @@ -53,6 +53,60 @@ function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } +/** + * Whether a required nested member counts as declared even though it is not + * spelled out: a bare `elicitation: {}` declaration (no mode sub-capability at + * all) is read as form support — the pre-mode (2025) meaning of a bare + * declaration — so an `elicitation.form` requirement treats it as satisfied. + * Declaring any mode explicitly (for example `elicitation: { url: {} }`) + * removes the implication. + */ +function isImpliedCapabilityMember(capability: string, member: string, declaredValue: Record): boolean { + return capability === 'elicitation' && member === 'form' && declaredValue['form'] === undefined && declaredValue['url'] === undefined; +} + +/** + * The client capabilities an embedded multi-round-trip input request requires + * (call site 2 — the outbound input-request leg): a server MUST NOT send an + * `inputRequests` kind the request's declared client capabilities do not + * cover. Returns `undefined` for entries whose method is not one of the + * embedded input-request kinds (those are a server bug handled separately, + * not a capability question). + * + * The requirement is mode-aware where the capability is: URL-mode elicitation + * requires `elicitation.url`; form-mode (or mode-omitted) elicitation requires + * `elicitation.form` (modes are sub-capabilities, and a server MUST NOT send a + * mode the client did not declare); sampling with `tools`/`toolChoice` + * requires `sampling.tools`. A bare `elicitation: {}` declaration satisfies + * the form requirement — see {@linkcode missingClientCapabilities}. + */ +export function requiredClientCapabilitiesForInputRequest(entry: { + method: string; + params?: Record; +}): ClientCapabilities | undefined { + switch (entry.method) { + case 'elicitation/create': { + if (entry.params?.['mode'] === 'url') { + return { elicitation: { url: {} } }; + } + return { elicitation: { form: {} } }; + } + case 'sampling/createMessage': { + const params = entry.params; + if (params !== undefined && (params['tools'] !== undefined || params['toolChoice'] !== undefined)) { + return { sampling: { tools: {} } }; + } + return { sampling: {} }; + } + case 'roots/list': { + return { roots: {} }; + } + default: { + return undefined; + } + } +} + /** * Computes the subset of `required` client capabilities the client did not * declare. Returns `undefined` when every required capability is declared; @@ -63,7 +117,10 @@ function isPlainObject(value: unknown): value is Record { * A capability counts as declared when its top-level key is present on the * declared capabilities; when the requirement names nested members (for * example `elicitation: { url: {} }`), each named member must also be present - * under the declared capability. An absent or empty `declared` value means + * under the declared capability. One lenient reading applies: a bare + * `elicitation: {}` declaration (no mode sub-capability at all) counts as + * declaring `elicitation.form` — the pre-mode (2025) meaning of a bare + * declaration. An absent or empty `declared` value means * nothing is declared — every required capability is missing (the structural * clean-refusal posture for sessions with no per-request capability view). */ @@ -85,7 +142,11 @@ export function missingClientCapabilities( if (isPlainObject(requirement) && isPlainObject(declaredValue)) { const missingMembers: Record = {}; for (const [member, memberRequirement] of Object.entries(requirement)) { - if (memberRequirement !== undefined && declaredValue[member] === undefined) { + if ( + memberRequirement !== undefined && + declaredValue[member] === undefined && + !isImpliedCapabilityMember(capability, member, declaredValue) + ) { missingMembers[member] = memberRequirement; } } diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index b90efbcb91..88b62e06cf 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -27,6 +27,12 @@ * A notification that does carry a claim is treated body-primary like a * request, and a malformed claim is rejected the same way a request's * malformed claim is — never silently resolved against the header. + * The notification-POST header cross-checks here are an SDK-defensive + * posture, not a spec requirement: the spec leaves header rules for posted + * notifications undefined (core client notifications do not occur over + * Streamable HTTP); applying the request rules symmetrically is what an + * ecosystem custom-notification POST expects, and the −32001 cells stay + * passing for them. * - `GET`/`DELETE` (and any other non-`POST` method) are body-less 2025-era * session operations: the modern era is `POST`-only, so they are routed to * legacy serving when it is configured and rejected otherwise. diff --git a/packages/core/test/shared/clientCapabilityRequirements.test.ts b/packages/core/test/shared/clientCapabilityRequirements.test.ts index 9b4c607586..80758d3916 100644 --- a/packages/core/test/shared/clientCapabilityRequirements.test.ts +++ b/packages/core/test/shared/clientCapabilityRequirements.test.ts @@ -12,6 +12,7 @@ import { describe, expect, test } from 'vitest'; import { missingClientCapabilities, REQUIRED_CLIENT_CAPABILITIES_BY_METHOD, + requiredClientCapabilitiesForInputRequest, requiredClientCapabilitiesForRequest } from '../../src/shared/clientCapabilityRequirements.js'; import { rev2026RequestMethods } from '../../src/wire/rev2026-07-28/registry.js'; @@ -39,6 +40,43 @@ describe('missingClientCapabilities', () => { test('an empty requirement object is always satisfied', () => { expect(missingClientCapabilities({}, undefined)).toBeUndefined(); }); + + test('a bare elicitation declaration implies form support (the pre-mode meaning), but not other modes', () => { + // Bare `elicitation: {}` satisfies the form requirement… + expect(missingClientCapabilities({ elicitation: { form: {} } }, { elicitation: {} })).toBeUndefined(); + // …but an explicit mode declaration removes the implication… + expect(missingClientCapabilities({ elicitation: { form: {} } }, { elicitation: { url: {} } })).toEqual({ + elicitation: { form: {} } + }); + // …and the bare declaration never implies URL support. + expect(missingClientCapabilities({ elicitation: { url: {} } }, { elicitation: {} })).toEqual({ elicitation: { url: {} } }); + }); +}); + +describe('requiredClientCapabilitiesForInputRequest', () => { + test('elicitation requirements are mode-aware sub-capabilities', () => { + expect(requiredClientCapabilitiesForInputRequest({ method: 'elicitation/create', params: { mode: 'url' } })).toEqual({ + elicitation: { url: {} } + }); + expect(requiredClientCapabilitiesForInputRequest({ method: 'elicitation/create', params: { mode: 'form' } })).toEqual({ + elicitation: { form: {} } + }); + // Mode omitted defaults to form. + expect(requiredClientCapabilitiesForInputRequest({ method: 'elicitation/create', params: { message: 'Name?' } })).toEqual({ + elicitation: { form: {} } + }); + }); + + test('sampling requires sampling.tools only when tools/toolChoice are present; roots requires roots; other methods are not input requests', () => { + expect(requiredClientCapabilitiesForInputRequest({ method: 'sampling/createMessage', params: { maxTokens: 5 } })).toEqual({ + sampling: {} + }); + expect( + requiredClientCapabilitiesForInputRequest({ method: 'sampling/createMessage', params: { maxTokens: 5, tools: [] } }) + ).toEqual({ sampling: { tools: {} } }); + expect(requiredClientCapabilitiesForInputRequest({ method: 'roots/list' })).toEqual({ roots: {} }); + expect(requiredClientCapabilitiesForInputRequest({ method: 'tools/call' })).toBeUndefined(); + }); }); describe('requiredClientCapabilitiesForRequest', () => { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4420e20712..f3f1a885d0 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -75,5 +75,11 @@ export { classifyInboundRequest } from '@modelcontextprotocol/core'; // the registerResource cacheHint option). export type { CacheHint, CacheScope } from '@modelcontextprotocol/core'; +// Multi round-trip requests (protocol revision 2026-07-28): the authoring +// helpers a handler uses to request additional client input by returning an +// input-required result instead of sending a server→client request. +export type { InputRequiredSpec } from '@modelcontextprotocol/core'; +export { acceptedContent, inputRequired } from '@modelcontextprotocol/core'; + // re-export curated public API from core export * from '@modelcontextprotocol/core/public'; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 6fcdd9a327..33f6408e9a 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -7,6 +7,7 @@ import type { CompleteResult, GetPromptResult, Implementation, + InputRequiredResult, ListPromptsResult, ListResourcesResult, ListToolsResult, @@ -30,6 +31,7 @@ import { assertCompleteRequestResourceTemplate, assertValidCacheHint, attachCacheHintFallback, + isInputRequiredResult, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, @@ -159,7 +161,7 @@ export class McpServer { }) ); - this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { + this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { const tool = this._registeredTools[request.params.name]; if (!tool) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); @@ -231,11 +233,17 @@ export class McpServer { /** * Validates tool output against the tool's output schema. */ - private async validateToolOutput(tool: RegisteredTool, result: CallToolResult, toolName: string): Promise { + private async validateToolOutput(tool: RegisteredTool, result: CallToolResult | InputRequiredResult, toolName: string): Promise { if (!tool.outputSchema) { return; } + // An input-required result is not the tool's final output: structured + // content is only required (and validated) on the completing result. + if (isInputRequiredResult(result)) { + return; + } + if (result.isError) { return; } @@ -260,7 +268,11 @@ export class McpServer { /** * Executes a tool handler. */ - private async executeToolHandler(tool: RegisteredTool, args: unknown, ctx: ServerContext): Promise { + private async executeToolHandler( + tool: RegisteredTool, + args: unknown, + ctx: ServerContext + ): Promise { // Executor encapsulates handler invocation with proper types return tool.executor(args, ctx); } @@ -469,7 +481,7 @@ export class McpServer { }) ); - this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { + this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { const prompt = this._registeredPrompts[request.params.name]; if (!prompt) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); @@ -1079,13 +1091,19 @@ export type InferRawShape = z.infer>; /** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ export type LegacyToolCallback = Args extends ZodRawShape - ? (args: InferRawShape, ctx: ServerContext) => CallToolResult | Promise - : (ctx: ServerContext) => CallToolResult | Promise; + ? ( + args: InferRawShape, + ctx: ServerContext + ) => CallToolResult | InputRequiredResult | Promise + : (ctx: ServerContext) => CallToolResult | InputRequiredResult | Promise; /** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ export type LegacyPromptCallback = Args extends ZodRawShape - ? (args: InferRawShape, ctx: ServerContext) => GetPromptResult | Promise - : (ctx: ServerContext) => GetPromptResult | Promise; + ? ( + args: InferRawShape, + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise + : (ctx: ServerContext) => GetPromptResult | InputRequiredResult | Promise; export type BaseToolCallback< SendResultT extends Result, @@ -1099,7 +1117,7 @@ export type BaseToolCallback< * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. */ export type ToolCallback = BaseToolCallback< - CallToolResult, + CallToolResult | InputRequiredResult, ServerContext, Args >; @@ -1112,7 +1130,7 @@ export type AnyToolHandler Promise; +type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; export type RegisteredTool = { title?: string; @@ -1157,7 +1175,9 @@ function createToolExecutor( } // When no inputSchema, call with just ctx (the handler expects (ctx) signature) - const callback = handler as (ctx: ServerContext) => CallToolResult | Promise; + const callback = handler as ( + ctx: ServerContext + ) => CallToolResult | InputRequiredResult | Promise; return async (_args, ctx) => callback(ctx); } @@ -1179,7 +1199,10 @@ export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult /** * Callback to read a resource at a given URI. */ -export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; +export type ReadResourceCallback = ( + uri: URL, + ctx: ServerContext +) => ReadResourceResult | InputRequiredResult | Promise; export type RegisteredResource = { name: string; @@ -1209,7 +1232,7 @@ export type ReadResourceTemplateCallback = ( uri: URL, variables: Variables, ctx: ServerContext -) => ReadResourceResult | Promise; +) => ReadResourceResult | InputRequiredResult | Promise; export type RegisteredResourceTemplate = { resourceTemplate: ResourceTemplate; @@ -1233,16 +1256,22 @@ export type RegisteredResourceTemplate = { }; export type PromptCallback = Args extends StandardSchemaWithJSON - ? (args: StandardSchemaWithJSON.InferOutput, ctx: ServerContext) => GetPromptResult | Promise - : (ctx: ServerContext) => GetPromptResult | Promise; + ? ( + args: StandardSchemaWithJSON.InferOutput, + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise + : (ctx: ServerContext) => GetPromptResult | InputRequiredResult | Promise; /** * Internal handler type that encapsulates parsing and callback invocation. * This allows type-safe handling without runtime type assertions. */ -type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; +type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; -type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; +type ToolCallbackInternal = ( + args: unknown, + ctx: ServerContext +) => CallToolResult | InputRequiredResult | Promise; export type RegisteredPrompt = { title?: string; @@ -1276,7 +1305,10 @@ function createPromptHandler( callback: PromptCallback ): PromptHandler { if (argsSchema) { - const typedCallback = callback as (args: unknown, ctx: ServerContext) => GetPromptResult | Promise; + const typedCallback = callback as ( + args: unknown, + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise; return async (args, ctx) => { const parseResult = await validateStandardSchema(argsSchema, args); @@ -1286,7 +1318,9 @@ function createPromptHandler( return typedCallback(parseResult.data, ctx); }; } else { - const typedCallback = callback as (ctx: ServerContext) => GetPromptResult | Promise; + const typedCallback = callback as ( + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise; return async (_args, ctx) => { return typedCallback(ctx); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 5e3633a6ff..905d6cae97 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -39,23 +39,36 @@ import type { import { assertValidCacheHint, attachCacheHintFallback, + CLIENT_CAPABILITIES_META_KEY, codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, + isInputRequiredResult, + isModernProtocolVersion, LATEST_PROTOCOL_VERSION, legacyProtocolVersions, LoggingLevelSchema, mergeCapabilities, + missingClientCapabilities, + MissingRequiredClientCapabilityError, modernProtocolVersions, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, + requiredClientCapabilitiesForInputRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +/** + * The request methods whose 2026-07-28 result vocabulary includes + * `input_required` (the multi round-trip methods). Returning an + * input-required result from any other handler is a server bug. + */ +const INPUT_REQUIRED_CAPABLE_METHODS: ReadonlySet = new Set(['tools/call', 'prompts/get', 'resources/read']); + export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -103,6 +116,33 @@ export type ServerOptions = ProtocolOptions & { * affected. Invalid values throw a `RangeError` at construction time. */ cacheHints?: Partial>; + + /** + * Multi-round-trip `requestState` integrity hook (protocol revision + * 2026-07-28). + */ + requestState?: { + /** + * Called on every re-entered multi-round-trip request that carries a + * `requestState` (i.e. whenever `ctx.mcpReq.requestState` is present), + * BEFORE the handler runs. Throw or reject to refuse the request: the + * seam answers with a wire-level `-32602` Invalid Params error whose + * message is frozen to `"Invalid or expired requestState"` and whose + * `data.reason` is `'invalid_request_state'` — the thrown reason is + * surfaced via the server's `onerror` callback only and never reaches + * the wire. + * + * This is the place to put HMAC or AEAD verification of + * `requestState`. The spec MUST for integrity-protecting state that + * influences authorization, resource access, or business logic is on + * the server author (basic/patterns/mrtr, server requirements 4–5); + * the SDK provides NO default verification. Leaving this option + * unconfigured keeps today's behavior — `ctx.mcpReq.requestState` is + * passed through raw and MUST be treated as attacker-controlled + * input. + */ + verify?: (state: string, ctx: ServerContext) => void | Promise; + }; }; /* @@ -186,6 +226,7 @@ export class Server extends Protocol { private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; private _cacheHints?: ServerOptions['cacheHints']; + private _requestStateVerify?: (state: string, ctx: ServerContext) => void | Promise; /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -203,6 +244,7 @@ export class Server extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this._requestStateVerify = options?.requestState?.verify; // Configured cache hints fail loudly at construction time (before any // handler registration consults them). @@ -307,9 +349,15 @@ export class Server extends Protocol { /** * Enforces server-side validation for `tools/call` results regardless of how the - * handler was registered, and attaches the configured per-operation cache hint + * handler was registered, attaches the configured per-operation cache hint * (when one exists) so the 2026-07-28 encode seam can fill `ttlMs`/`cacheScope` - * for results that do not provide their own. The hint rides a symbol-keyed + * for results that do not provide their own, and owns the multi-round-trip + * seam: on the methods whose 2026-07-28 result vocabulary includes + * `input_required` (`tools/call`, `prompts/get`, `resources/read`) an + * input-required return skips result-schema validation and is checked + * against the served era, the at-least-one rule, and the request's own + * declared client capabilities; on every other method an input-required + * return is a server bug and fails loudly. The hint rides a symbol-keyed * property that is never serialized, so 2025-era responses are unaffected. */ protected override _wrapHandler( @@ -318,10 +366,41 @@ export class Server extends Protocol { ): (request: JSONRPCRequest, ctx: ServerContext) => Promise { if (method !== 'tools/call') { const cacheHint = (this._cacheHints as Record | undefined)?.[method]; - if (cacheHint === undefined) { - return handler; + const isInputRequiredCapable = INPUT_REQUIRED_CAPABLE_METHODS.has(method); + if (cacheHint === undefined && !isInputRequiredCapable) { + // Server-bug guard: an input-required return from a method + // whose result vocabulary does not include it is never + // mis-typed onto the wire. + return async (request, ctx) => { + const result = await handler(request, ctx); + if (isInputRequiredResult(result)) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but only tools/call, prompts/get and ` + + `resources/read support input_required (protocol revision 2026-07-28)` + ); + } + return result; + }; } - return async (request, ctx) => attachCacheHintFallback(await handler(request, ctx), cacheHint); + return async (request, ctx) => { + const result = isInputRequiredCapable + ? await this._invokeInputRequiredCapableHandler(method, handler, request, ctx) + : await handler(request, ctx); + if (isInputRequiredResult(result)) { + if (!isInputRequiredCapable) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but only tools/call, prompts/get and ` + + `resources/read support input_required (protocol revision 2026-07-28)` + ); + } + // Never cache-stamped (the encode contract skips + // non-complete results); the hint is not attached. + return result; + } + return cacheHint === undefined ? result : attachCacheHintFallback(result, cacheHint); + }; } return async (request, ctx) => { // Era-exact validation: the request and result schemas come from @@ -343,7 +422,13 @@ export class Server extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); } - const result = await handler(request, ctx); + const result = await this._invokeInputRequiredCapableHandler('tools/call', handler, request, ctx); + if (isInputRequiredResult(result)) { + // Already checked by the seam; the CallToolResult schema does + // not apply to it (no widening — InputRequiredResult travels + // alongside). + return result; + } const validationResult = parseSchema(callToolResultSchema, result); if (!validationResult.success) { @@ -356,6 +441,179 @@ export class Server extends Protocol { }; } + /** + * Whether this instance is bound to a 2026-07-28-or-later protocol + * revision. Era is instance state — a serving entry (`createMcpHandler`, + * `serveStdio`) marks the instance modern at construction; a 2025-era + * `initialize` handshake binds it legacy. The multi-round-trip seam reads + * this directly: there is no per-request era consult. + */ + private _servedModernEra(): boolean { + return this._negotiatedProtocolVersion !== undefined && isModernProtocolVersion(this._negotiatedProtocolVersion); + } + + /** + * Invokes a handler for one of the multi-round-trip methods and applies + * the input-required seam: + * + * - a `UrlElicitationRequiredError` (or any 2025-style server→client + * request idiom) escaping the handler on a request served on the + * 2026-07-28 era fails LOUDLY with a clear steer to + * `inputRequired.elicitUrl(...)` — the `-32042` error never reaches the + * 2026-07-28 wire and the throw is not silently converted. Requests + * served on the 2025 era keep today's `-32042` behavior byte-exact (the + * error is rethrown unchanged). + * - an input-required RETURN is only legal toward the 2026-07-28 era; it + * must satisfy the at-least-one rule (`inputRequests` or + * `requestState`), and every embedded request must be covered by the + * capabilities the client declared on this request's envelope + * (violations answer with the typed `-32003` error). + */ + private async _invokeInputRequiredCapableHandler( + method: string, + handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise, + request: JSONRPCRequest, + ctx: ServerContext + ): Promise { + const servedModern = this._servedModernEra(); + + // The configured requestState.verify hook runs above the handler (and + // therefore above the McpServer tools/call funnel), so a rejection + // reaches the wire as a real JSON-RPC error rather than an `isError` + // tool result. The wire message is FROZEN — the thrown reason is + // surfaced via `onerror` only. A non-string `requestState` value (the + // wire field is `string | undefined`) is treated as invalid regardless + // of whether a hook is configured, so a malformed value cannot bypass + // verification. + const rawRequestState = ctx.mcpReq.requestState as unknown; + if (rawRequestState !== undefined && typeof rawRequestState !== 'string') { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', { + reason: 'invalid_request_state' + }); + } + if (this._requestStateVerify !== undefined && typeof rawRequestState === 'string') { + try { + await this._requestStateVerify(rawRequestState, ctx); + } catch (error) { + this.onerror?.( + new Error(`requestState verification rejected ${method}: ${error instanceof Error ? error.message : String(error)}`) + ); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', { + reason: 'invalid_request_state' + }); + } + } + + let result: Result; + try { + result = await handler(request, ctx); + } catch (error) { + if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { + if (!servedModern) { + // 2025-era behavior is frozen: the error reaches the wire + // exactly as it does today. + throw error; + } + // 2026-era requests do not carry the -32042 surface. A + // 2025-style throw fails loudly with a clear steer rather than + // being converted: the handler should return + // inputRequired.elicitUrl(...) instead. + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `URL elicitation cannot be signalled by throwing UrlElicitationRequiredError on protocol revision ` + + `${this._negotiatedProtocolVersion}: return inputRequired({ inputRequests: { …: inputRequired.elicitUrl(...) } }) ` + + `from the handler instead. The urlElicitationRequired error (-32042) of earlier revisions is not ` + + `available on this revision.` + ); + } + throw error; + } + + if (!isInputRequiredResult(result)) { + return result; + } + + if (!servedModern) { + // The 2025-era wire has no input_required vocabulary: fail loudly + // rather than putting a mis-typed result on the wire. A handler + // that serves both eras branches on the served era and uses the + // push-style APIs toward 2025-era requests. + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but this request is served on protocol revision ` + + `${this._negotiatedProtocolVersion ?? LATEST_PROTOCOL_VERSION}, which has no input_required vocabulary` + ); + } + + // F7 at-least-one re-check (hand-built results are legal; the rule is + // re-checked at the seam). + const inputRequests = result.inputRequests as Record | null | undefined; + const hasInputRequests = inputRequests != null && Object.keys(inputRequests).length > 0; + const hasRequestState = typeof result.requestState === 'string'; + if (!hasInputRequests && !hasRequestState) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result with neither inputRequests nor requestState ` + + `(every InputRequiredResult must include at least one of the two)` + ); + } + + // Per-embedded-request capability check against the capabilities the + // client declared on THIS request's envelope (-32003 on violation). + if (hasInputRequests) { + const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; + for (const [key, entry] of Object.entries(inputRequests)) { + if (entry === null || typeof entry !== 'object' || typeof (entry as { method?: unknown }).method !== 'string') { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an invalid input request '${key}': each inputRequests entry must be an ` + + `embedded elicitation/create, sampling/createMessage, or roots/list request` + ); + } + const embedded = entry as { method: string; params?: Record }; + const required = requiredClientCapabilitiesForInputRequest(embedded); + if (required === undefined) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input request '${key}' of kind '${embedded.method}', which is not an ` + + `embedded request the 2026-07-28 revision defines` + ); + } + const missing = missingClientCapabilities(required, declared); + if (missing !== undefined) { + throw new MissingRequiredClientCapabilityError( + { requiredCapabilities: missing }, + `Cannot request input '${key}' (${embedded.method}): the request's client capabilities do not declare ` + + `the required capability` + ); + } + } + } + + return result; + } + + /** + * Guard for the push-style server→client request APIs ({@linkcode createMessage}, + * {@linkcode elicitInput}, {@linkcode listRoots}, {@linkcode ping}) on a + * modern-era instance: the 2026-07-28 revision has no server→client request + * channel, so the call fails before any wire traffic with a typed error + * whose message steers to `inputRequired(...)`. The base era gate would + * also reject it; this guard runs first to carry the steer. + */ + private _assertPushApiInServedEra(method: string): void { + if (this._servedModernEra()) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Server-to-client requests are not available on protocol revision ${this._negotiatedProtocolVersion}: ` + + `'${method}' cannot be sent while serving a request on that revision. ` + + `Return inputRequired({ ... }) from the handler instead — the client fulfils the embedded ` + + `requests and retries the original request (multi round-trip requests).`, + { method, era: '2026-07-28' } + ); + } + } + protected assertCapabilityForMethod(method: RequestMethod | string): void { switch (method) { case 'sampling/createMessage': { @@ -592,6 +850,7 @@ export class Server extends Protocol { } async ping(): Promise { + this._assertPushApiInServedEra('ping'); return this.request({ method: 'ping' }); } @@ -633,6 +892,7 @@ export class Server extends Protocol { params: CreateMessageRequest['params'], options?: RequestOptions ): Promise { + this._assertPushApiInServedEra('sampling/createMessage'); // Capability check - only required when tools/toolChoice are provided if ((params.tools || params.toolChoice) && !this._clientCapabilities?.sampling?.tools) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); @@ -702,6 +962,7 @@ export class Server extends Protocol { * @returns The result of the elicitation request. */ async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { + this._assertPushApiInServedEra('elicitation/create'); const mode = (params.mode ?? 'form') as 'form' | 'url'; switch (mode) { @@ -792,6 +1053,7 @@ export class Server extends Protocol { * Migrate to passing paths via tool parameters, resource URIs, or configuration. */ async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise { + this._assertPushApiInServedEra('roots/list'); return this.request({ method: 'roots/list', params }, options); } diff --git a/packages/server/test/server/inputRequired.test.ts b/packages/server/test/server/inputRequired.test.ts new file mode 100644 index 0000000000..9d0e1d26c4 --- /dev/null +++ b/packages/server/test/server/inputRequired.test.ts @@ -0,0 +1,455 @@ +/** + * Server-side multi-round-trip seam (M4.1): + * + * - a handler for tools/call, prompts/get, or resources/read returns an + * input-required result on a 2026-07-28-classified request and it reaches + * the wire as `resultType: 'input_required'` (validateToolOutput and the + * tools/call result schema are skipped for it; cache fields are never + * stamped on it); + * - the guards: at-least-one re-check for hand-built results, the per-embedded + * -request `-32003` capability check against the request's OWN envelope + * capabilities, the server-bug guard (non-multi-round-trip methods, and any + * method on a 2025-era request, never put a mis-typed result on the wire); + * - a UrlElicitationRequiredError escaping a handler on the modern era fails + * LOUDLY (clear steer to inputRequired.elicitUrl(...), never converted) — + * `-32042` never reaches the 2026-07-28 wire — while 2025-era traffic keeps + * today's `-32042` behavior; + * - the push-style APIs loud-fail on 2026-era requests with the + * `inputRequired(...)` steer surfaced through the tools/call catch-all, with + * zero wire traffic emitted for the attempted server→client request; + * - the write-once re-entry: a retried request's `inputResponses` reach the + * handler via ctx and the final result passes full validation. + */ +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse +} from '@modelcontextprotocol/core'; +import { + acceptedContent, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + inputRequired, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion, + UrlElicitationRequiredError +} from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp.js'; +import type { ServerOptions } from '../../src/server/server.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN = '2026-07-28'; + +const envelope = (clientCapabilities: Record = {}) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'mrtr-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: clientCapabilities +}); + +async function wire(server: McpServer | Server, options?: { era?: 'modern' | 'legacy' }) { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + const waiters = new Map void>(); + peerTx.onmessage = message => { + inbound.push(message); + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + await server.connect(serverTx); + await peerTx.start(); + // Era is instance state: a serving entry binds the instance modern; for + // these unit tests we bind directly via the package-internal setter (the + // way createMcpHandler/serveStdio do). + if (options?.era === 'modern') { + setNegotiatedProtocolVersion(server instanceof Server ? server : server.server, MODERN); + } + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); + return { request, notify, inbound, close: () => server.close() }; +} + +const modernToolCall = ( + id: number, + name: string, + args: Record = {}, + options?: { clientCapabilities?: Record; extraParams?: Record } +): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { + _meta: envelope(options?.clientCapabilities ?? {}), + name, + arguments: args, + ...options?.extraParams + } +}); + +const legacyInitialize = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +function resultOf(message: JSONRPCMessage): Record { + return (message as JSONRPCResultResponse).result as unknown as Record; +} + +function errorOf(message: JSONRPCMessage): { code: number; message: string; data?: unknown } { + return (message as JSONRPCErrorResponse).error; +} + +describe('input-required returns on the 2026-07-28 era', () => { + it('a write-once tool returning inputRequired() reaches the wire as input_required and completes on the retry', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool( + 'deploy', + { inputSchema: z.object({ env: z.string() }), outputSchema: z.object({ deployed: z.boolean() }) }, + async ({ env }, ctx) => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Deploy to ${env}?`, + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } } } + }) + }, + requestState: 'opaque-deploy-state' + }); + } + return { content: [{ type: 'text', text: 'deployed' }], structuredContent: { deployed: true } }; + } + ); + const { request, close } = await wire(server, { era: 'modern' }); + + // First leg: input_required goes out, with no cache stamping and the + // structured-content requirement skipped. + const first = resultOf( + await request(modernToolCall(1, 'deploy', { env: 'prod' }, { clientCapabilities: { elicitation: { form: {} } } })) + ); + expect(first.resultType).toBe('input_required'); + expect(first.requestState).toBe('opaque-deploy-state'); + expect(first.inputRequests).toMatchObject({ confirm: { method: 'elicitation/create' } }); + expect(first.ttlMs).toBeUndefined(); + expect(first.cacheScope).toBeUndefined(); + expect(first.content).toBeUndefined(); + + // Retry leg (fresh id, responses + byte-exact echo): full validation + // applies to the completing result, which is stamped 'complete'. + const second = resultOf( + await request( + modernToolCall( + 2, + 'deploy', + { env: 'prod' }, + { + clientCapabilities: { elicitation: { form: {} } }, + extraParams: { + inputResponses: { confirm: { action: 'accept', content: { confirm: true } } }, + requestState: 'opaque-deploy-state' + } + } + ) + ) + ); + expect(second.resultType).toBe('complete'); + expect(second.structuredContent).toEqual({ deployed: true }); + + await close(); + }); + + it('prompts/get and resources/read handlers can return input_required (no catch-all rewraps it)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { prompts: {}, resources: {} } }); + server.registerPrompt('wizard', { argsSchema: z.object({}) }, async () => inputRequired({ requestState: 'prompt-state' })); + server.registerResource('secret', 'file:///secret.txt', {}, async () => inputRequired({ requestState: 'resource-state' })); + const { request, close } = await wire(server, { era: 'modern' }); + + const promptResult = resultOf( + await request({ + jsonrpc: '2.0', + id: 1, + method: 'prompts/get', + params: { _meta: envelope(), name: 'wizard', arguments: {} } + }) + ); + expect(promptResult.resultType).toBe('input_required'); + expect(promptResult.requestState).toBe('prompt-state'); + + const resourceResult = resultOf( + await request({ + jsonrpc: '2.0', + id: 2, + method: 'resources/read', + params: { _meta: envelope(), uri: 'file:///secret.txt' } + }) + ); + expect(resourceResult.resultType).toBe('input_required'); + expect(resourceResult.requestState).toBe('resource-state'); + expect(resourceResult.ttlMs).toBeUndefined(); + + await close(); + }); +}); + +describe('guards', () => { + it('hand-built results missing both inputRequests and requestState fail loudly (at-least-one re-check)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('broken', { inputSchema: z.object({}) }, async () => ({ resultType: 'input_required' }) as never); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = await request(modernToolCall(1, 'broken')); + expect(errorOf(answer).code).toBe(-32_603); + expect(JSON.stringify(answer)).not.toContain('"resultType":"input_required"'); + + await close(); + }); + + it('checks every embedded request against the capabilities the request itself declared (-32003 on violation)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('ask', { inputSchema: z.object({}) }, async () => + inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ message: 'OK?', requestedSchema: { type: 'object', properties: {} } }) + } + }) + ); + server.registerTool('open-url', { inputSchema: z.object({}) }, async () => + inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ message: 'Sign in', url: 'https://example.com' }) + } + }) + ); + const { request, close } = await wire(server, { era: 'modern' }); + + // No elicitation capability declared on the request → -32003 naming + // the form sub-capability the embedded form-mode elicitation needs. + const noCapability = await request(modernToolCall(1, 'ask', {}, { clientCapabilities: {} })); + expect(errorOf(noCapability).code).toBe(-32_003); + expect(errorOf(noCapability).data).toMatchObject({ requiredCapabilities: { elicitation: { form: {} } } }); + + // Form-mode capability declared → the same tool is served. + const withCapability = await request(modernToolCall(2, 'ask', {}, { clientCapabilities: { elicitation: { form: {} } } })); + expect(resultOf(withCapability).resultType).toBe('input_required'); + + // URL-mode embedded request requires elicitation.url specifically. + const urlWithoutUrlCapability = await request( + modernToolCall(3, 'open-url', {}, { clientCapabilities: { elicitation: { form: {} } } }) + ); + expect(errorOf(urlWithoutUrlCapability).code).toBe(-32_003); + expect(errorOf(urlWithoutUrlCapability).data).toMatchObject({ requiredCapabilities: { elicitation: { url: {} } } }); + + // Form-mode embedded request toward a URL-only client → -32003: modes + // are sub-capabilities and the server must not send an undeclared one. + const formTowardUrlOnly = await request(modernToolCall(4, 'ask', {}, { clientCapabilities: { elicitation: { url: {} } } })); + expect(errorOf(formTowardUrlOnly).code).toBe(-32_003); + expect(errorOf(formTowardUrlOnly).data).toMatchObject({ requiredCapabilities: { elicitation: { form: {} } } }); + + // A bare `elicitation: {}` declaration is read as form support (the + // pre-mode meaning of a bare declaration) → served. + const bareElicitation = await request(modernToolCall(5, 'ask', {}, { clientCapabilities: { elicitation: {} } })); + expect(resultOf(bareElicitation).resultType).toBe('input_required'); + + await close(); + }); + + it('a 2025-era request never sees an input_required result: the server fails loudly instead (server-bug guard)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('deploy', { inputSchema: z.object({}) }, async () => inputRequired({ requestState: 'state' })); + const { request, close } = await wire(server); + + await request(legacyInitialize(1)); + const answer = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'deploy', arguments: {} } }); + expect(errorOf(answer).code).toBe(-32_603); + // The mis-typed result never reaches the wire: the answer is an error, not a result. + expect((answer as { result?: unknown }).result).toBeUndefined(); + + await close(); + }); + + it('non-multi-round-trip methods can never emit input_required (server-bug guard)', async () => { + const server = new Server({ name: 's', version: '1.0.0' }, { capabilities: { completions: {} } }); + server.setRequestHandler('completion/complete', async () => ({ resultType: 'input_required', requestState: 's' }) as never); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = await request({ + jsonrpc: '2.0', + id: 1, + method: 'completion/complete', + params: { + _meta: envelope(), + ref: { type: 'ref/prompt', name: 'p' }, + argument: { name: 'a', value: 'v' } + } + }); + expect(errorOf(answer).code).toBe(-32_603); + // The mis-typed result never reaches the wire: the answer is an error, not a result. + expect((answer as { result?: unknown }).result).toBeUndefined(); + + await close(); + }); +}); + +describe('UrlElicitationRequiredError (the 2025-era -32042 idiom)', () => { + const URL_PARAMS = { mode: 'url' as const, message: 'Sign in to continue', elicitationId: 'elicit-7', url: 'https://example.com/auth' }; + + function buildUrlThrowingServer() { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('protected', { inputSchema: z.object({}) }, async () => { + throw new UrlElicitationRequiredError([URL_PARAMS]); + }); + return server; + } + + it('fails LOUDLY on a 2026-era request with a clear inputRequired.elicitUrl(...) steer — never converted, never -32042', async () => { + const { request, close } = await wire(buildUrlThrowingServer(), { era: 'modern' }); + + const answer = await request(modernToolCall(1, 'protected', {}, { clientCapabilities: { elicitation: { url: {} } } })); + expect(errorOf(answer).code).toBe(-32_603); + expect(errorOf(answer).message).toContain('inputRequired.elicitUrl'); + expect(JSON.stringify(answer)).not.toContain('"resultType":"input_required"'); + // The -32042 error code never appears on the 2026-07-28 wire (the steer + // text mentions it for migration; the wire error code is InternalError). + expect(JSON.stringify(answer)).not.toContain('"code":-32042'); + + await close(); + }); + + it('keeps the exact -32042 behavior for 2025-era traffic', async () => { + const { request, close } = await wire(buildUrlThrowingServer()); + + await request(legacyInitialize(1)); + const answer = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'protected', arguments: {} } }); + const error = errorOf(answer); + expect(error.code).toBe(-32_042); + expect(error.data).toEqual({ elicitations: [URL_PARAMS] }); + + await close(); + }); +}); + +describe('requestState.verify hook', () => { + function buildServer(options?: ServerOptions) { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} }, ...options }); + const handler = vi.fn(async () => ({ content: [{ type: 'text' as const, text: 'ok' }] })); + server.registerTool('deploy', { inputSchema: z.object({}) }, handler); + return { server, handler }; + } + + const reentry = (id: number, requestState?: string) => + modernToolCall(id, 'deploy', {}, { extraParams: requestState === undefined ? {} : { requestState } }); + + it('is called with the echoed state and the handler context, before the handler', async () => { + const seen: Array<{ state: string; method: string }> = []; + const { server, handler } = buildServer({ + requestState: { verify: (state, ctx) => void seen.push({ state, method: ctx.mcpReq.method }) } + }); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = resultOf(await request(reentry(1, 'signed-state'))); + expect(seen).toEqual([{ state: 'signed-state', method: 'tools/call' }]); + expect(handler).toHaveBeenCalledOnce(); + expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]); + + await close(); + }); + + it('a throw becomes the frozen -32602 wire error (not an isError tool result); the reason goes to onerror only', async () => { + const { server, handler } = buildServer({ + requestState: { + verify: () => { + throw new Error('HMAC mismatch — granular reason'); + } + } + }); + const onerror = vi.fn(); + server.server.onerror = onerror; + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = await request(reentry(1, 'tampered')); + // Real JSON-RPC error (above the tools/call funnel), not a result. + expect((answer as { result?: unknown }).result).toBeUndefined(); + const error = errorOf(answer); + expect(error.code).toBe(-32_602); + expect(error.message).toBe('Invalid or expired requestState'); + expect(error.data).toEqual({ reason: 'invalid_request_state' }); + // The granular reason never reaches the wire — onerror only. + expect(JSON.stringify(answer)).not.toContain('HMAC mismatch'); + expect(onerror).toHaveBeenCalledOnce(); + expect(String(onerror.mock.calls[0]?.[0])).toContain('HMAC mismatch'); + expect(handler).not.toHaveBeenCalled(); + + await close(); + }); + + it('is not called when the request carries no requestState', async () => { + const verify = vi.fn(); + const { server, handler } = buildServer({ requestState: { verify } }); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = resultOf(await request(reentry(1))); + expect(verify).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledOnce(); + expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]); + + await close(); + }); + + it('not configured → today’s behavior (raw passthrough; the handler reads the state itself)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + let seen: string | undefined; + server.registerTool('deploy', { inputSchema: z.object({}) }, async (_args, ctx) => { + seen = ctx.mcpReq.requestState; + return { content: [{ type: 'text', text: 'ok' }] }; + }); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = resultOf(await request(reentry(1, 'raw-state'))); + expect(seen).toBe('raw-state'); + expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]); + + await close(); + }); +}); + +describe('push-style APIs on 2026-era requests', () => { + it('ctx.mcpReq.elicitInput rejects before any wire traffic and the catch-all surfaces the inputRequired() steer as isError', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('legacy-style', { inputSchema: z.object({}) }, async (_args, ctx) => { + const answer = await ctx.mcpReq.elicitInput({ message: 'Name?', requestedSchema: { type: 'object', properties: {} } }); + return { content: [{ type: 'text', text: JSON.stringify(answer) }] }; + }); + const { request, inbound, close } = await wire(server, { era: 'modern' }); + + const answer = await request(modernToolCall(1, 'legacy-style', {}, { clientCapabilities: { elicitation: { form: {} } } })); + const result = resultOf(answer); + expect(result.isError).toBe(true); + const text = JSON.stringify(result.content); + expect(text).toContain('inputRequired('); + + // Zero wire traffic for the attempted server→client request: the only + // message the peer ever received is the tools/call response itself. + expect(inbound.filter(message => (message as { method?: string }).method === 'elicitation/create')).toHaveLength(0); + expect(inbound).toHaveLength(1); + + await close(); + }); +}); diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index c79c27b3c4..ce4a19e93e 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -162,11 +162,16 @@ export async function wire( return response; }; let clientTx = new StreamableHTTPClientTransport(url, { fetch }); + // entryModern is the era-fixed 2026-07-28 arm: it is the only arm + // whose wire may legitimately carry input_required results, so it + // opts the sniffer into accepting them (other arms stay strict). + let armSniff: WireOptions = sniff; if (transport === 'entryModern') { pinModernNegotiation(client); clientTx = attachModernEnvelope(clientTx); + armSniff = { allowInputRequiredResults: true, ...sniff }; } - await client.connect(sniffTransport(clientTx, 'client', sniff)); + await client.connect(sniffTransport(clientTx, 'client', armSniff)); if (transport === 'entryModern') assertModernNegotiation(client); return { fetch, diff --git a/test/e2e/helpers/wire-sniffer.test.ts b/test/e2e/helpers/wire-sniffer.test.ts index 73ea7222e8..ca072217a9 100644 --- a/test/e2e/helpers/wire-sniffer.test.ts +++ b/test/e2e/helpers/wire-sniffer.test.ts @@ -61,6 +61,19 @@ describe('assertWireMessage', () => { expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'server')).not.toThrow(); }); + it('rejects an input_required server result unless the cell opted in (modern-era arms only)', () => { + const inputRequired = resp({ + resultType: 'input_required', + inputRequests: { ask: { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } } + }); + // Default (legacy-era cells): input_required is not legal wire vocabulary. + expect(() => assertWireMessage(inputRequired, 'server')).toThrow(/invalid message/); + // Modern-era arms opt in explicitly. + expect(() => assertWireMessage(inputRequired, 'server', { allowInputRequiredResults: true })).not.toThrow(); + // The opt-in never applies to client-sent results. + expect(() => assertWireMessage(inputRequired, 'client', { allowInputRequiredResults: true })).toThrow(/invalid message/); + }); + it('accepts a JSON-RPC error response for either party', () => { const err = { jsonrpc: '2.0' as const, id: 1, error: { code: -32_601, message: 'Method not found' } }; expect(() => assertWireMessage(err, 'server')).not.toThrow(); diff --git a/test/e2e/helpers/wire-sniffer.ts b/test/e2e/helpers/wire-sniffer.ts index 89663214ce..3a5dc2fc3f 100644 --- a/test/e2e/helpers/wire-sniffer.ts +++ b/test/e2e/helpers/wire-sniffer.ts @@ -8,6 +8,7 @@ import { } from '@modelcontextprotocol/core'; import type { Transport } from '@modelcontextprotocol/server'; import { + isInputRequiredResult, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, @@ -22,6 +23,13 @@ export interface SnifferOptions { allowCustomMethods?: boolean; /** `false` → envelope check only (for tests that deliberately send malformed messages). */ strictValidation?: boolean; + /** + * Permit `input_required` results as server output. Set automatically by + * the wiring for the modern-era (2026-07-28) arms — multi-round-trip + * results are not legal vocabulary on the 2025-era wire, so an + * `input_required` leaking onto a legacy cell is flagged. + */ + allowInputRequiredResults?: boolean; } const OUTBOUND = { @@ -87,6 +95,12 @@ export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferO if (isJSONRPCResultResponse(msg)) { const result = (msg as { result: unknown }).result; + // Multi-round-trip results (protocol revision 2026-07-28) are valid + // server output but deliberately NOT part of the neutral result union + // (InputRequiredResultSchema lives alongside, never widening it). + // Era-gated: only cells wired for the modern era opt in, so an + // input_required on a 2025-era cell's wire is still flagged. + if (party === 'server' && opts.allowInputRequiredResults === true && isInputRequiredResult(result)) return; const r = schemas.result.safeParse(result); if (!r.success) { // A result for a vendor-extension request legitimately won't match the spec union. diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 2c919fbc2d..48c715ab66 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -514,7 +514,10 @@ export const REQUIREMENTS: Record = { 'mcpserver:tool:url-elicitation-error': { source: 'sdk', behavior: - 'A tool function that raises the URL-elicitation-required error surfaces to the caller as error -32042 with the elicitation parameters intact.' + 'A tool function that raises the URL-elicitation-required error surfaces to the caller as error -32042 with the elicitation parameters intact.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'typescript:mrtr:url-elicitation:no-32042-on-2026', + note: 'The body asserts the legacy -32042 error surface; on the 2026-07-28 era URL elicitation rides multi round-trip results instead (the supersedes link names that surface).' }, 'typescript:mcpserver:tool:schema-variants': { source: 'sdk', @@ -1061,18 +1064,16 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#completion-notifications-for-url-mode-elicitation', behavior: 'The client ignores an elicitation/complete notification referencing an unknown or already-completed elicitationId without error.', - entryExclusions: [ - { - arm: 'entryModern', - reason: 'method-not-in-modern-registry', - note: 'notifications/elicitation/complete was removed from the 2026-07-28 draft; on that revision the client drops it as an unknown notification (the row asserts ignored-without-error against received copies, which never arrive)' - } - ] + removedInSpecVersion: '2026-07-28', + note: 'Retired on the 2026-07-28 era: notifications/elicitation/complete is removed from the draft schema (spec PR #2891), so there is no notification for the modern client to ignore.' }, 'elicitation:url:required-error': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-elicitation-required-error', behavior: - 'A handler that cannot proceed without a URL elicitation rejects the request with error -32042, carrying the pending elicitations in the error data.' + 'A handler that cannot proceed without a URL elicitation rejects the request with error -32042, carrying the pending elicitations in the error data.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'typescript:mrtr:url-elicitation:no-32042-on-2026', + note: 'The body asserts the legacy -32042 error surface; on the 2026-07-28 era URL elicitation rides multi round-trip results instead (the supersedes link names that surface).' }, 'elicitation:form:response-validation': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#form-mode-security', @@ -2618,6 +2619,47 @@ export const REQUIREMENTS: Record = { 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.' }, + // Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) + 'typescript:mrtr:tools-call:write-once-roundtrip': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', + behavior: + 'A write-once tool that returns inputRequired() on a 2026-07-28 connection is fulfilled by the client auto-fulfilment driver: the registered elicitation handler answers the embedded request, and the original call is retried with a fresh request id, a byte-exact requestState echo, and the collected inputResponses, completing as a plain CallToolResult.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Runs on the entryModern arm; the input_required wire shape, the fresh request id, and the byte-exact requestState echo are asserted on the arm-recorded HTTP exchanges.' + }, + 'typescript:mrtr:push-api:loud-fail-2026': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', + behavior: + 'The push-style server→client APIs (e.g. ctx.mcpReq.elicitInput) on a 2026-07-28 request fail with a typed local error before any wire traffic; in a tool handler the error surfaces as an isError result whose text steers to inputRequired(...).', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Runs on the entryModern arm; the absence of any server→client request on the wire is asserted on the arm-recorded HTTP bytes.' + }, + 'typescript:mrtr:url-elicitation:no-32042-on-2026': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', + behavior: + 'URL-mode elicitation rides the multi-round-trip flow on the 2026-07-28 era: a tool handler that returns inputRequired.elicitUrl(...) embeds a URL-mode elicitation/create in an input_required result (capability-gated by -32003 on elicitation.url), the registered elicitation handler fulfils it, the retried call completes, and the urlElicitationRequired error code (-32042) never appears on the wire.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['mcpserver:tool:url-elicitation-error', 'elicitation:url:required-error'], + note: 'Runs on the entryModern arm; the input_required wire shape and the absence of -32042 anywhere in the exchange are asserted on the arm-recorded HTTP bytes.' + }, + 'typescript:mrtr:rounds-cap': { + source: 'sdk', + behavior: + 'The client auto-fulfilment driver is bounded: when a server keeps answering input_required, the call fails with the typed InputRequiredRoundsExceeded error (carrying the last input_required payload) once the configurable inputRequired.maxRounds cap is exhausted, instead of looping forever.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Runs on the entryModern arm so the round count can be asserted directly on the arm-recorded HTTP exchanges.' + }, + 'typescript:mrtr:legacy-32042-freeze': { + source: 'sdk', + behavior: + 'On 2025-era serving, a UrlElicitationRequiredError thrown by a tool handler still reaches the client as the exact urlElicitationRequired protocol error: code -32042 with data.elicitations carrying the URL-mode elicitation params, byte-identical to the pre-multi-round-trip behavior.', + removedInSpecVersion: '2026-07-28', + note: 'Bounded to the 2025-11-25 axis: this is the freeze cell pinning that the 2026-07-28 era guard leaves the deployed -32042 surface untouched on legacy serving.' + }, // Legacy SSE 'transport:sse:server-transport': { source: 'sdk', diff --git a/test/e2e/scenarios/mrtr.test.ts b/test/e2e/scenarios/mrtr.test.ts new file mode 100644 index 0000000000..5899a4bafd --- /dev/null +++ b/test/e2e/scenarios/mrtr.test.ts @@ -0,0 +1,233 @@ +/** + * Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) through + * the public surface: a write-once tool returning inputRequired() is + * fulfilled by the client's registered elicitation handler and retried with + * fresh ids + a byte-exact requestState echo; push-style server→client APIs + * loud-fail on 2026-era requests with the inputRequired() steer; URL-mode + * elicitation rides the flow with zero -32042 on the 2026 wire; the + * auto-fulfilment driver is bounded by inputRequired.maxRounds; and 2025-era + * serving keeps the exact -32042 behavior (the freeze cell). + * + * The 2026-era cells run on the entryModern arm (per-request modern hosting); + * raw wire facts are asserted on the arm-recorded HTTP exchanges. + */ +import { Client, SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; +import { acceptedContent, inputRequired, McpServer, ProtocolError, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import type { Wired } from '../helpers/index.js'; +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Every JSON-RPC request the wired client POSTed for the given method, in order. */ +function recordedRequests(wired: Wired, method: string): Array> { + const requests: Array> = []; + for (const exchange of wired.httpLog ?? []) { + if (exchange.requestBody === undefined) continue; + try { + const parsed = JSON.parse(exchange.requestBody) as Record; + if (parsed.method === method) requests.push(parsed); + } catch { + // Not a JSON body (e.g. an empty notification POST) — skip it. + } + } + return requests; +} + +/** All recorded HTTP bytes (request bodies + response bodies) concatenated, for absence assertions. */ +async function allRecordedBytes(wired: Wired): Promise { + const responses = await Promise.all((wired.httpLog ?? []).map(exchange => exchange.response.text())); + const requests = (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? ''); + return [...requests, ...responses].join('\n'); +} + +const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] }; + +verifies('typescript:mrtr:tools-call:write-once-roundtrip', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) }, + requestState: 'opaque-deploy-state' + }); + } + return { content: [{ type: 'text', text: `deployed to ${env}` }] }; + }); + return server; + }; + + const client = new Client( + { name: 'mrtr-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } + ); + const handled: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + handled.push(request.params); + return { action: 'accept', content: { confirm: true } }; + }); + + await using wired = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed to prod' }]); + expect('resultType' in result).toBe(false); + + // The registered handler fulfilled the embedded elicitation. + expect(handled).toHaveLength(1); + expect(handled[0]).toMatchObject({ mode: 'form', message: 'Deploy to prod?' }); + + // Two independent wire legs with fresh ids; the retry carries the bare + // response and the byte-exact requestState echo alongside the original params. + const toolCalls = recordedRequests(wired, 'tools/call'); + expect(toolCalls).toHaveLength(2); + expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id); + const retryParams = toolCalls[1]!.params as Record; + expect(retryParams.name).toBe('deploy'); + expect(retryParams.arguments).toEqual({ env: 'prod' }); + expect(retryParams.requestState).toBe('opaque-deploy-state'); + expect(retryParams.inputResponses).toEqual({ confirm: { action: 'accept', content: { confirm: true } } }); +}); + +verifies('typescript:mrtr:push-api:loud-fail-2026', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('legacy-style', { inputSchema: z.object({}) }, async (_args, ctx) => { + // The pre-2026 pattern: pushing a server→client elicitation request. + const answer = await ctx.mcpReq.elicitInput({ message: 'Name?', requestedSchema: { type: 'object', properties: {} } }); + return { content: [{ type: 'text', text: JSON.stringify(answer) }] }; + }); + return server; + }; + + const client = new Client( + { name: 'mrtr-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } + ); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: {} })); + + await using wired = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'legacy-style', arguments: {} }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('inputRequired('); + + // The attempted server→client request never produced wire traffic: no + // elicitation/create request appears in any recorded exchange. + const bytes = await allRecordedBytes(wired); + expect(bytes).not.toContain('"method":"elicitation/create"'); +}); + +verifies('typescript:mrtr:url-elicitation:no-32042-on-2026', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('protected', { inputSchema: z.object({}) }, async (_args, ctx) => { + if (ctx.mcpReq.inputResponses?.['auth'] !== undefined) { + return { content: [{ type: 'text', text: 'authorized' }] }; + } + // The 2026-07-28 idiom: return an embedded URL-mode elicitation + // (the 2025-style throw is not converted on this era). + return inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ + message: 'Sign in to continue', + url: 'https://example.com/auth' + }) + } + }); + }); + return server; + }; + + const client = new Client( + { name: 'mrtr-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { url: {} } } } + ); + const seenUrlRequests: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + seenUrlRequests.push(request.params); + // URL mode: the user completes the interaction out of band; the + // response carries no content. + return { action: 'accept' }; + }); + + await using wired = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'protected', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'authorized' }]); + expect(seenUrlRequests).toHaveLength(1); + expect(seenUrlRequests[0]).toMatchObject({ mode: 'url', url: 'https://example.com/auth' }); + + // The -32042 error code never appears on the 2026 wire; the + // input_required result is what travelled instead. + const bytes = await allRecordedBytes(wired); + expect(bytes).not.toContain('32042'); + expect(bytes).toContain('"resultType":"input_required"'); +}); + +verifies('typescript:mrtr:rounds-cap', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('insatiable', { inputSchema: z.object({}) }, async () => + inputRequired({ + inputRequests: { more: inputRequired.elicit({ message: 'More input?', requestedSchema: CONFIRM_SCHEMA }) }, + requestState: 'never-enough' + }) + ); + return server; + }; + + const client = new Client( + { name: 'mrtr-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } }, inputRequired: { maxRounds: 2 } } + ); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { confirm: true } })); + + await using wired = await wire(transport, makeServer, client); + + const outcome = await client.callTool({ name: 'insatiable', arguments: {} }).then( + value => ({ resolved: value as unknown }), + error => ({ rejected: error as unknown }) + ); + expect('rejected' in outcome, 'the call must not resolve').toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InputRequiredRoundsExceeded); + expect((rejection as SdkError).data).toMatchObject({ rounds: 2, lastResult: { requestState: 'never-enough' } }); + + // The cap bounded the wire traffic: the original call plus exactly two retries. + expect(recordedRequests(wired, 'tools/call')).toHaveLength(3); +}); + +verifies('typescript:mrtr:legacy-32042-freeze', async ({ transport }: TestArgs) => { + const URL_PARAMS = { + mode: 'url' as const, + message: 'Sign in to continue', + elicitationId: 'auth-legacy', + url: 'https://example.com/auth' + }; + const makeServer = () => { + const server = new McpServer({ name: 'legacy-url-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('protected', { inputSchema: z.object({}) }, async () => { + throw new UrlElicitationRequiredError([URL_PARAMS]); + }); + return server; + }; + const client = new Client({ name: 'legacy-url-client', version: '1.0.0' }, { capabilities: { elicitation: { url: {} } } }); + + await using _ = await wire(transport, makeServer, client); + + const outcome = await client.callTool({ name: 'protected', arguments: {} }).then( + value => ({ resolved: value as unknown }), + error => ({ rejected: error as unknown }) + ); + expect('rejected' in outcome, 'the -32042 error must surface, not a result').toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(ProtocolError); + expect((rejection as ProtocolError).code).toBe(-32_042); + expect((rejection as ProtocolError).data).toEqual({ elicitations: [URL_PARAMS] }); +}); From f7c29e85a01c46befa300eaab3aa994846357c1c Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:10:06 +0100 Subject: [PATCH 27/37] test(conformance): arm the fixtures for the input-required scenario family (#2319) --- .../expected-failures.2026-07-28.yaml | 21 - test/conformance/expected-failures.yaml | 24 +- test/conformance/src/everythingClient.ts | 101 +++++ test/conformance/src/everythingServer.ts | 390 +++++++++++++++++- 4 files changed, 490 insertions(+), 46 deletions(-) diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index 21792ec3a3..5c30ea0ade 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -48,9 +48,6 @@ client: - auth/scope-retry-limit # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- - # SEP-2322 (multi-round-trip requests): client does not echo requestState / - # handle IncompleteResult yet. - - sep-2322-client-request-state # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers @@ -86,21 +83,3 @@ server: # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, # so those reject cells are still accepted with 200. - http-header-validation - # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented - # in the SDK, so the fixture does not register the scenarios' diagnostic - # test_input_required_result_* tools. - - input-required-result-basic-elicitation - - input-required-result-basic-sampling - - input-required-result-basic-list-roots - - input-required-result-request-state - - input-required-result-multiple-input-requests - - input-required-result-multi-round - - input-required-result-non-tool-request - - input-required-result-result-type - - input-required-result-tampered-state - - input-required-result-capability-check - # SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, - # ignore unrecognized inputResponses keys): WARNING-only, but the - # expected-failures evaluator counts WARNINGs as failures. - - input-required-result-missing-input-response - - input-required-result-ignore-extra-params diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index b22573d3f8..c5ab22325a 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -18,9 +18,6 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2322 (multi-round-trip requests): client does not echo requestState / - # handle IncompleteResult yet. - - sep-2322-client-request-state # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers @@ -52,29 +49,12 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) --- - # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented - # in the SDK, so the fixture does not register the scenarios' diagnostic - # test_input_required_result_* tools. - - input-required-result-basic-elicitation - - input-required-result-basic-sampling - - input-required-result-basic-list-roots - - input-required-result-request-state - - input-required-result-multiple-input-requests - - input-required-result-multi-round - - input-required-result-non-tool-request - - input-required-result-result-type - - input-required-result-tampered-state - - input-required-result-capability-check # SEP-2243 (HTTP header standardization): the reject cells the SDK does # answer now use -32001 (HeaderMismatch), but missing-header enforcement # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, # so those reject cells are still accepted with 200. - http-header-validation - # WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level - # WARNINGs, but the expected-failures evaluator counts WARNINGs as failures. + # WARNING-only entry: the scenario emits no FAILURE checks, only a SHOULD-level + # WARNING, but the expected-failures evaluator counts WARNINGs as failures. # SEP-2164: server returns -32002 without the requested URI in error.data. - sep-2164-resource-not-found - # SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore - # unrecognized inputResponses keys). - - input-required-result-missing-input-response - - input-required-result-ignore-extra-params diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index e58f5558c3..a619678bec 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -235,6 +235,107 @@ registerScenario('initialize', runBasicClient); registerScenario('tools_call', runToolsCallClient); registerScenario('request-metadata', runRequestMetadataClient); +// ============================================================================ +// Multi-round-trip client scenario (SEP-2322, protocol revision 2026-07-28) +// ============================================================================ + +/** + * The multi-round-trip client scenario's mock server only implements + * `tools/list`, `tools/call` and `notifications/initialized`; it answers both + * `server/discover` and `initialize` with -32601, so neither connect-time + * negotiation path can establish the 2026-07-28 era against it. The scenario + * is pinned to 2026-07-28 (the runner resolves it there even on the + * default-version leg), so the fixture answers the connect-time + * `server/discover` probe locally through the transport's custom fetch and + * lets every other request reach the real mock. Everything the scenario + * measures — auto-fulfilment of the embedded elicitation, the byte-exact + * requestState echo, fresh JSON-RPC ids on retries, isolation of unrelated + * calls, and not retrying complete results — is the SDK driver's behavior + * against the real mock. + */ +function withLocalDiscoverResponse(serverInfo: { name: string; version: string }): typeof fetch { + return async (input, init) => { + if (typeof init?.body === 'string') { + try { + const message = JSON.parse(init.body) as { method?: string; id?: unknown }; + if (message.method === 'server/discover') { + return Response.json( + { + jsonrpc: '2.0', + id: message.id, + result: { + supportedVersions: ['2026-07-28'], + capabilities: { tools: { listChanged: true } }, + serverInfo + } + }, + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + } catch { + // Not a JSON-RPC body — fall through to the real fetch. + } + } + return fetch(input, init); + }; +} + +async function runMrtrClient(serverUrl: string): Promise { + const clientInfo = { name: 'test-client', version: '1.0.0' }; + const capabilities = { elicitation: {} }; + const client = new Client(clientInfo, { + capabilities, + versionNegotiation: { mode: 'auto' } + }); + + // The auto-fulfilment driver dispatches the embedded elicitation requests + // to this handler, exactly like a server-initiated elicitation. + client.setRequestHandler('elicitation/create', async request => { + logger.debug('Fulfilling embedded elicitation request:', JSON.stringify(request.params)); + return { action: 'accept' as const, content: { confirmed: true } }; + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: withLocalDiscoverResponse(clientInfo) + }); + + await client.connect(transport); + logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); + + const envelope = modernEnvelope(clientInfo, capabilities, client.getNegotiatedProtocolVersion()); + + // requestState echo flow: the driver must echo the opaque state byte-exact + // and retry on a fresh JSON-RPC id. + const echoResult = await client.callTool({ name: 'test_mrtr_echo_state', arguments: {}, _meta: envelope }); + logger.debug('test_mrtr_echo_state result:', JSON.stringify(echoResult)); + + // No-state flow: the InputRequiredResult carries no requestState, so the + // retry must not include one. + const noStateResult = await client.callTool({ name: 'test_mrtr_no_state', arguments: {}, _meta: envelope }); + logger.debug('test_mrtr_no_state result:', JSON.stringify(noStateResult)); + + // Unrelated call: must not carry inputResponses or requestState from the + // multi-round-trip flows above. + const unrelatedResult = await client.callTool({ name: 'test_mrtr_unrelated', arguments: {}, _meta: envelope }); + logger.debug('test_mrtr_unrelated result:', JSON.stringify(unrelatedResult)); + + // Result without resultType: the check passes as long as the client does + // not retry with inputResponses. The SDK treats a missing resultType from + // a 2026-negotiated server as a protocol violation and rejects locally + // without retrying, so this call is expected to throw. + try { + const noResultTypeResult = await client.callTool({ name: 'test_mrtr_no_result_type', arguments: {}, _meta: envelope }); + logger.debug('test_mrtr_no_result_type result:', JSON.stringify(noResultTypeResult)); + } catch (error) { + logger.debug('test_mrtr_no_result_type rejected locally (no retry):', error instanceof Error ? error.message : String(error)); + } + + await client.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('sep-2322-client-request-state', runMrtrClient); + // ============================================================================ // Auth scenarios - well-behaved client // ============================================================================ diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 16c7d49be1..535b4e1221 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -7,12 +7,33 @@ * This server is designed to pass all conformance test scenarios. */ -import { randomUUID } from 'node:crypto'; +import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { localhostHostValidation } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult, EventId, EventStore, GetPromptResult, ReadResourceResult, StreamId } from '@modelcontextprotocol/server'; -import { classifyInboundRequest, createMcpHandler, isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import type { + CallToolResult, + EventId, + EventStore, + GetPromptResult, + InputRequests, + InputRequiredResult, + ReadResourceResult, + ServerContext, + StreamId +} from '@modelcontextprotocol/server'; +import { + acceptedContent, + classifyInboundRequest, + CLIENT_CAPABILITIES_META_KEY, + createMcpHandler, + inputRequired, + isInitializeRequest, + McpServer, + ProtocolError, + ProtocolErrorCode, + ResourceTemplate +} from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -64,6 +85,43 @@ const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQ // Sample base64 encoded minimal WAV file for testing const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; +// ===== MULTI-ROUND-TRIP requestState INTEGRITY (SEP-2322) ===== +// +// `requestState` round-trips through the client and comes back as +// attacker-controlled input. The SDK treats it as an opaque string and applies +// no protection of its own, so a server that lets it influence behavior MUST +// integrity-protect it when minting and MUST reject state that fails +// verification (see the migration guide). This pair of helpers is the worked +// example of that obligation: the payload is HMAC-signed with a per-process +// key and verified on every retry. The key is process-local because the +// 2026-07-28 path serves every request from a fresh server instance — the +// state itself is the only thing that survives between rounds. +const REQUEST_STATE_HMAC_KEY = randomBytes(32); + +function mintRequestState(payload: Record): string { + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signature = createHmac('sha256', REQUEST_STATE_HMAC_KEY).update(body).digest('base64url'); + return `${body}.${signature}`; +} + +function verifyRequestState(state: string): Record | undefined { + const separator = state.lastIndexOf('.'); + if (separator <= 0) { + return undefined; + } + const body = state.slice(0, separator); + const expected = createHmac('sha256', REQUEST_STATE_HMAC_KEY).update(body).digest(); + const provided = Buffer.from(state.slice(separator + 1), 'base64url'); + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + return undefined; + } + try { + return JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Record; + } catch { + return undefined; + } +} + // Function to create a new MCP server instance (one per session) function createMcpServer() { const mcpServer = new McpServer( @@ -85,6 +143,17 @@ function createMcpServer() { }, logging: {}, completions: {} + }, + // Seam-level integrity check (SEP-2322): every re-entered MRTR + // request that carries requestState is verified before the handler + // runs. A rejection answers a wire-level -32602 with + // data.reason 'invalid_request_state'. + requestState: { + verify: state => { + if (verifyRequestState(state) === undefined) { + throw new Error('requestState failed integrity verification'); + } + } } } ); @@ -624,6 +693,280 @@ function createMcpServer() { } ); + // ===== MULTI-ROUND-TRIP TOOLS (SEP-2322, protocol revision 2026-07-28) ===== + // + // Diagnostic tools for the input-required conformance scenarios. Each tool + // is written write-once style: it returns `inputRequired(...)` until the + // retried request carries the responses it needs (read from + // `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`), then completes. + // These tools are only meaningful toward 2026-07-28 requests; calling them + // on a 2025-era session fails loudly at the server seam by design. + + // Basic elicitation round trip. Also exercised by the result-type, + // missing-input-response, ignore-extra-params and validate-input + // scenarios: anything that does not contain an accepted "user_name" + // response is answered with a fresh InputRequiredResult re-requesting it. + mcpServer.registerTool( + 'test_input_required_result_elicitation', + { + description: 'MRTR (SEP-2322): asks for the caller name via an in-band elicitation request', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const name = acceptedContent<{ name: string }>(ctx.mcpReq.inputResponses, 'user_name')?.name; + if (typeof name !== 'string') { + return inputRequired({ + inputRequests: { + user_name: inputRequired.elicit({ + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + }) + } + }); + } + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + ); + + // Basic sampling round trip. + mcpServer.registerTool( + 'test_input_required_result_sampling', + { + description: 'MRTR (SEP-2322): asks for an LLM completion via an in-band sampling request', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const samplingResponse = ctx.mcpReq.inputResponses?.['capital_question'] as + | { content?: { type?: string; text?: string } } + | undefined; + if (samplingResponse === undefined) { + return inputRequired({ + inputRequests: { + capital_question: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'What is the capital of France?' } }], + maxTokens: 100 + }) + } + }); + } + const text = + typeof samplingResponse.content?.text === 'string' ? samplingResponse.content.text : JSON.stringify(samplingResponse); + return { content: [{ type: 'text', text: `Sampling response: ${text}` }] }; + } + ); + + // Basic roots/list round trip. + mcpServer.registerTool( + 'test_input_required_result_list_roots', + { + description: 'MRTR (SEP-2322): asks for the client roots via an in-band roots/list request', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const rootsResponse = ctx.mcpReq.inputResponses?.['client_roots'] as + | { roots?: Array<{ uri?: string; name?: string }> } + | undefined; + if (!Array.isArray(rootsResponse?.roots)) { + return inputRequired({ inputRequests: { client_roots: inputRequired.listRoots() } }); + } + const uris = rootsResponse.roots.map(root => root.uri).join(', '); + return { content: [{ type: 'text', text: `Client exposed ${rootsResponse.roots.length} root(s): ${uris}` }] }; + } + ); + + // requestState round trip: the state is integrity-protected when minted + // and verified on the retry (see the helpers above). + mcpServer.registerTool( + 'test_input_required_result_request_state', + { + description: 'MRTR (SEP-2322): round-trips integrity-protected requestState alongside an elicitation request', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const confirmation = acceptedContent<{ ok: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmation === undefined) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Please confirm', + requestedSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'] + } + }) + }, + requestState: mintRequestState({ tool: 'request_state', nonce: randomUUID() }) + }); + } + const state = ctx.mcpReq.requestState === undefined ? undefined : verifyRequestState(ctx.mcpReq.requestState); + if (state === undefined) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid requestState: missing or failed integrity verification'); + } + return { content: [{ type: 'text', text: 'state-ok: requestState verified and confirmation received' }] }; + } + ); + + // Multiple input requests of different kinds in one InputRequiredResult. + mcpServer.registerTool( + 'test_input_required_result_multiple_inputs', + { + description: 'MRTR (SEP-2322): asks for elicitation, sampling and roots input in a single round', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const responses = ctx.mcpReq.inputResponses; + const name = acceptedContent<{ name: string }>(responses, 'user_name')?.name; + const greeting = (responses?.['greeting'] as { content?: { text?: string } } | undefined)?.content?.text; + const roots = (responses?.['client_roots'] as { roots?: unknown[] } | undefined)?.roots; + if (typeof name !== 'string' || typeof greeting !== 'string' || !Array.isArray(roots)) { + return inputRequired({ + inputRequests: { + user_name: inputRequired.elicit({ + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + }), + greeting: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'Generate a greeting' } }], + maxTokens: 50 + }), + client_roots: inputRequired.listRoots() + }, + requestState: mintRequestState({ tool: 'multiple_inputs', nonce: randomUUID() }) + }); + } + return { content: [{ type: 'text', text: `${greeting} ${name} — ${roots.length} root(s) visible` }] }; + } + ); + + // Multi-round flow: the round number lives in the integrity-protected + // requestState (the 2026-07-28 path keeps no per-session state), and the + // state changes between rounds. + mcpServer.registerTool( + 'test_input_required_result_multi_round', + { + description: 'MRTR (SEP-2322): two elicitation rounds with evolving requestState before completing', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const state = ctx.mcpReq.requestState === undefined ? undefined : verifyRequestState(ctx.mcpReq.requestState); + const round = state?.tool === 'multi_round' && typeof state.round === 'number' ? state.round : 0; + if (round === 0) { + return inputRequired({ + inputRequests: { + step1: inputRequired.elicit({ + message: 'Step 1: What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + }) + }, + requestState: mintRequestState({ tool: 'multi_round', round: 1, nonce: randomUUID() }) + }); + } + if (round === 1) { + const name = acceptedContent<{ name: string }>(ctx.mcpReq.inputResponses, 'step1')?.name ?? 'unknown'; + return inputRequired({ + inputRequests: { + step2: inputRequired.elicit({ + message: 'Step 2: What is your favorite color?', + requestedSchema: { + type: 'object', + properties: { color: { type: 'string' } }, + required: ['color'] + } + }) + }, + requestState: mintRequestState({ tool: 'multi_round', round: 2, name, nonce: randomUUID() }) + }); + } + const color = acceptedContent<{ color: string }>(ctx.mcpReq.inputResponses, 'step2')?.color ?? 'unknown'; + return { content: [{ type: 'text', text: `Multi-round complete: ${String(state?.name ?? 'unknown')} likes ${color}` }] }; + } + ); + + // Tampered-state rejection: the seam-level `requestState.verify` hook + // (configured on the McpServer above) rejects a retry whose requestState + // fails the fixture's HMAC before this handler runs, answering the + // wire-level -32602 the conformance scenario requires. The handler only + // sees verified state. + mcpServer.registerTool( + 'test_input_required_result_tampered_state', + { + description: 'MRTR (SEP-2322): rejects retries whose requestState fails integrity verification', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + if (ctx.mcpReq.requestState !== undefined && acceptedContent(ctx.mcpReq.inputResponses, 'confirm') !== undefined) { + return { content: [{ type: 'text', text: 'integrity-ok: requestState verified' }] }; + } + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Please confirm', + requestedSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'] + } + }) + }, + requestState: mintRequestState({ tool: 'tampered_state', nonce: randomUUID() }) + }); + } + ); + + // Capability-aware input requests: only ask for kinds the request's + // declared client capabilities cover (the server seam enforces the same + // rule with a -32003 error; the tool simply never trips it). + mcpServer.registerTool( + 'test_input_required_result_capabilities', + { + description: 'MRTR (SEP-2322): only requests input kinds the declared client capabilities cover', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + if (ctx.mcpReq.inputResponses !== undefined) { + return { content: [{ type: 'text', text: 'Capability-aware input requests fulfilled' }] }; + } + const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY]; + const inputRequests: InputRequests = {}; + if (declared?.elicitation !== undefined) { + inputRequests.user_name = inputRequired.elicit({ + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + }); + } + if (declared?.sampling !== undefined) { + inputRequests.greeting = inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'Generate a short greeting' } }], + maxTokens: 50 + }); + } + if (declared?.roots !== undefined) { + inputRequests.client_roots = inputRequired.listRoots(); + } + if (Object.keys(inputRequests).length === 0) { + return { content: [{ type: 'text', text: 'No declared client capability supports an in-band input request' }] }; + } + return inputRequired({ inputRequests }); + } + ); + // ===== RESOURCES ===== // Static text resource @@ -820,6 +1163,47 @@ function createMcpServer() { } ); + // Multi-round-trip prompt (SEP-2322): prompts/get is one of the methods + // whose 2026-07-28 result vocabulary includes input_required, so a prompt + // can request elicitation input in-band before rendering. + mcpServer.registerPrompt( + 'test_input_required_result_prompt', + { + title: 'MRTR Prompt', + description: 'MRTR (SEP-2322): prompt that requires elicitation input before rendering' + }, + async (ctx): Promise => { + // A prompt registered without argsSchema receives the request + // context as its only callback argument, but the registerPrompt + // overloads only model the (args, ctx) form — so the parameter + // arrives untyped and is narrowed here. + const promptCtx = ctx as ServerContext; + const promptContext = acceptedContent<{ context: string }>(promptCtx.mcpReq.inputResponses, 'user_context')?.context; + if (typeof promptContext !== 'string') { + return inputRequired({ + inputRequests: { + user_context: inputRequired.elicit({ + message: 'What context should the prompt use?', + requestedSchema: { + type: 'object', + properties: { context: { type: 'string' } }, + required: ['context'] + } + }) + } + }); + } + return { + messages: [ + { + role: 'user', + content: { type: 'text', text: `Use the following context: ${promptContext}` } + } + ] + }; + } + ); + // Prompt with image mcpServer.registerPrompt( 'test_prompt_with_image', From 7f425ee392c99c20c8961094f64d1afa17b762b2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:23:54 +0100 Subject: [PATCH 28/37] feat(client): per-request envelope auto-emission and probe completion (#2320) --- .changeset/envelope-auto-emission.md | 11 + docs/client.md | 20 ++ docs/migration.md | 15 +- examples/client/src/clientGuide.examples.ts | 14 + examples/client/src/dualEraStdioClient.ts | 23 +- examples/client/src/multiRoundTripClient.ts | 32 +-- examples/server/src/dualEraStreamableHttp.ts | 10 +- packages/client/src/client/client.ts | 74 +++++- .../client/src/client/versionNegotiation.ts | 20 ++ .../test/client/envelopeAutoEmission.test.ts | 248 ++++++++++++++++++ .../test/client/versionNegotiation.test.ts | 38 +++ packages/core/src/exports/public/index.ts | 3 + packages/core/src/shared/protocol.ts | 47 +++- packages/core/src/shared/protocolEras.ts | 7 + test/conformance/src/everythingClient.ts | 42 +-- test/e2e/CLAUDE.md | 4 +- test/e2e/helpers/index.ts | 72 +---- .../scenarios/hosting-entry-stamping.test.ts | 2 +- .../scenarios/hosting-entry-streaming.test.ts | 15 +- test/e2e/scenarios/hosting-entry.test.ts | 10 +- test/e2e/scenarios/mrtr.test.ts | 17 +- test/e2e/scenarios/protocol.test.ts | 5 +- test/e2e/scenarios/stdio-dual-era.test.ts | 14 +- .../test/server/createMcpHandler.test.ts | 29 +- .../test/server/dualEraStdio.test.ts | 15 +- 25 files changed, 556 insertions(+), 231 deletions(-) create mode 100644 .changeset/envelope-auto-emission.md create mode 100644 packages/client/test/client/envelopeAutoEmission.test.ts diff --git a/.changeset/envelope-auto-emission.md b/.changeset/envelope-auto-emission.md new file mode 100644 index 0000000000..d832c64d8d --- /dev/null +++ b/.changeset/envelope-auto-emission.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +--- + +Per-request `_meta` envelope auto-emission on modern-era connections: once a client negotiates a 2026-07-28+ protocol revision (via `versionNegotiation: { mode: 'auto' }` or `{ pin }`), it automatically attaches the reserved protocol-version / client-info / client-capabilities +`_meta` keys to every outgoing request and notification — you no longer set the envelope by hand. User-supplied `_meta` keys take precedence over the auto-attached ones; the auto-attached client-capabilities reflect what the client actually registered. Legacy-era connections +(the default, and the `'auto'`-mode fallback) never gain these keys, so 2025-era outbound traffic is byte-identical to before. + +Adds `Client.getProtocolEra()` (`'legacy' | 'modern' | undefined`), the `ProtocolEra` type, `Client.setVersionNegotiation()` for configuring negotiation pre-connect on an already-constructed instance, and the `probe.maxRetries` knob (default `0`) which governs probe-timeout +re-sends only — the spec-mandated `-32004` corrective continuation is never counted against it. The `versionNegotiation` default remains `'legacy'`: absent (or `mode: 'legacy'`), `connect()` runs the plain 2025 sequence, byte-identical to a v1.x client. diff --git a/docs/client.md b/docs/client.md index c2bb5b05b1..042ba2861a 100644 --- a/docs/client.md +++ b/docs/client.md @@ -88,6 +88,26 @@ try { For a complete example with error reporting, see [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/streamableHttpWithSseFallbackClient.ts). +### Protocol version negotiation (2026-07-28 revision) + +By default the client negotiates a 2025-era protocol version via the `initialize` handshake — exactly the v1.x behavior, byte for byte. To talk to a server on the 2026-07-28 revision, opt into version negotiation via `ClientOptions.versionNegotiation`: + +```ts source="../examples/client/src/clientGuide.examples.ts#Client_versionNegotiation" +// Auto-negotiate: probe with server/discover, fall back to the 2025 handshake +// against a 2025-only server. +const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await client.connect(transport); + +client.getProtocolEra(); // 'modern' or 'legacy' +client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' +``` + +- **absent / `mode: 'legacy'` (the default)** — today's 2025 connect sequence; no probe, no new headers. +- **`mode: 'auto'`** — `connect()` probes with `server/discover`; a 2025-only server rejects the probe and the client falls back to the plain `initialize` handshake on the same connection, byte-equivalent to a 2025 client. The probe costs one round trip against an old server. +- **`mode: { pin: '2026-07-28' }`** — modern era at exactly that revision; no fallback. Against a 2025-only server `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). + +Once a modern era is negotiated, the client automatically attaches the per-request `_meta` envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [migration guide](./migration.md#opt-in-protocol-version-negotiation-2026-07-28-draft) for the full failure semantics, probe policy, and the `'auto'`-mode compatibility table. + ### Disconnecting Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error. diff --git a/docs/migration.md b/docs/migration.md index 688d4e4bbe..445304b986 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1024,15 +1024,22 @@ Probe policy is configured under `versionNegotiation.probe`: versionNegotiation: { mode: 'auto', probe: { - timeoutMs: 10_000 // default: the standard request timeout + timeoutMs: 10_000, // default: the standard request timeout + maxRetries: 0 // default: no retries — governs timeout re-sends only } } ``` +`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an +already-constructed instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). + +Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass +in a request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. + On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize` -handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and other -long-lived connections) is the `serveStdio` entry point described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation -probe already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. +handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and +other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that +protocol revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` diff --git a/examples/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts index 99a8383bc8..b44f78a223 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/client/src/clientGuide.examples.ts @@ -78,6 +78,19 @@ async function connect_sseFallback(url: string) { //#endregion connect_sseFallback } +/** Example: Opt into 2026-07-28 protocol version negotiation. */ +async function Client_versionNegotiation(transport: StreamableHTTPClientTransport) { + //#region Client_versionNegotiation + // Auto-negotiate: probe with server/discover, fall back to the 2025 handshake + // against a 2025-only server. + const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + client.getProtocolEra(); // 'modern' or 'legacy' + client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' + //#endregion Client_versionNegotiation +} + // --------------------------------------------------------------------------- // Disconnecting // --------------------------------------------------------------------------- @@ -599,6 +612,7 @@ async function resumptionToken_basic(client: Client) { void connect_streamableHttp; void connect_stdio; void connect_sseFallback; +void Client_versionNegotiation; void disconnect_streamableHttp; void serverInstructions_basic; void auth_tokenProvider; diff --git a/examples/client/src/dualEraStdioClient.ts b/examples/client/src/dualEraStdioClient.ts index a8b4a6e317..9a9f6fe864 100644 --- a/examples/client/src/dualEraStdioClient.ts +++ b/examples/client/src/dualEraStdioClient.ts @@ -6,10 +6,8 @@ * 1. a plain 2025 client — the `initialize` handshake, served exactly as today; * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the * `server/discover` probe negotiates the 2026-07-28 revision on the pipe - * (no `initialize` is ever sent), and each modern request carries the - * per-request `_meta` envelope. (Attaching the envelope explicitly is a - * stop-gap: automatic per-request envelope emission is a client-side - * follow-up.) + * (no `initialize` is ever sent), and the client attaches the per-request + * `_meta` envelope to every outgoing request itself. * * The client spawns the server example directly from source over stdio: * @@ -17,7 +15,7 @@ */ import path from 'node:path'; -import { Client, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/client'; +import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; // Spawn the sibling server example straight from its source (no build step), @@ -46,20 +44,9 @@ async function modernLeg(): Promise { const client = new Client({ name: 'modern-demo-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StdioClientTransport(SERVER)); - const negotiated = client.getNegotiatedProtocolVersion(); - console.log('negotiated protocol version:', negotiated); - - // The per-request envelope every 2026-era request carries on the wire. - const envelope = { - [PROTOCOL_VERSION_META_KEY]: negotiated, - [CLIENT_INFO_META_KEY]: { name: 'modern-demo-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; + console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); - const result = await client.request({ - method: 'tools/call', - params: { name: 'greet', arguments: { name: '2026 client' }, _meta: envelope } - }); + const result = await client.callTool({ name: 'greet', arguments: { name: '2026 client' } }); console.log('greet result:', JSON.stringify(result.content)); await client.close(); } diff --git a/examples/client/src/multiRoundTripClient.ts b/examples/client/src/multiRoundTripClient.ts index 68068806a1..13921bdd95 100644 --- a/examples/client/src/multiRoundTripClient.ts +++ b/examples/client/src/multiRoundTripClient.ts @@ -16,33 +16,13 @@ * Start the server first, then: * * tsx examples/client/src/multiRoundTripClient.ts - * - * (Attaching the per-request `_meta` envelope explicitly is a stop-gap; - * automatic envelope emission for every request is a client-side follow-up.) */ import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/client'; -import { - Client, - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - isInputRequiredResult, - PROTOCOL_VERSION_META_KEY, - StreamableHTTPClientTransport -} from '@modelcontextprotocol/client'; +import { Client, isInputRequiredResult, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/'; const CLIENT_INFO = { name: 'mrtr-example-client', version: '1.0.0' }; -// Per-request envelope every 2026-era request carries on the wire. The -// declared client capabilities are what the server's −32003 check reads. -function envelope(negotiated: string): Record { - return { - [PROTOCOL_VERSION_META_KEY]: negotiated, - [CLIENT_INFO_META_KEY]: CLIENT_INFO, - [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {}, url: {} } } - }; -} - async function autoFulfilLeg(): Promise { console.log('--- auto-fulfilment (the default) ---'); const client = new Client(CLIENT_INFO, { @@ -62,15 +42,11 @@ async function autoFulfilLeg(): Promise { }); await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - const negotiated = client.getNegotiatedProtocolVersion()!; - console.log('negotiated protocol version:', negotiated); + console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); // callTool returns a plain CallToolResult — the interactive rounds happen // inside the call. - const result = await client.request({ - method: 'tools/call', - params: { name: 'deploy', arguments: { env: 'prod' }, _meta: envelope(negotiated) } - }); + const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); console.log('deploy result:', JSON.stringify(result.content)); await client.close(); } @@ -83,7 +59,6 @@ async function manualLeg(): Promise { inputRequired: { autoFulfill: false } }); await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - const negotiated = client.getNegotiatedProtocolVersion()!; let inputResponses: Record | undefined; let requestState: string | undefined; @@ -98,7 +73,6 @@ async function manualLeg(): Promise { params: { name: 'deploy', arguments: { env: 'staging' }, - _meta: envelope(negotiated), ...(inputResponses && { inputResponses }), ...(requestState && { requestState }) } diff --git a/examples/server/src/dualEraStreamableHttp.ts b/examples/server/src/dualEraStreamableHttp.ts index 5891bb5bc4..0ade70f793 100644 --- a/examples/server/src/dualEraStreamableHttp.ts +++ b/examples/server/src/dualEraStreamableHttp.ts @@ -21,12 +21,10 @@ * Run with `tsx examples/server/src/dualEraStreamableHttp.ts`, then point any * plain 2025 client at http://localhost:3000/mcp (served through the legacy * fallback unless `reject` is selected). A `versionNegotiation: { mode: 'auto' }` - * client negotiates 2026-07-28 against the same endpoint, but automatic - * envelope emission for every request is still a client-side follow-up: - * ordinary typed calls (for example `callTool`) must attach the per-request - * `_meta` envelope explicitly for now (see - * `test/integration/test/server/createMcpHandler.test.ts` for the pattern), - * or the endpoint rejects them on the header/body cross-check. + * client negotiates 2026-07-28 against the same endpoint and attaches the + * per-request `_meta` envelope itself once a modern era is negotiated, so + * ordinary typed calls (for example `callTool`) work against the modern leg + * without any per-call plumbing. */ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import type { CallToolResult, CreateMcpHandlerOptions, McpRequestContext } from '@modelcontextprotocol/server'; diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 0d9d40c6af..4eef2f2ec8 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -34,6 +34,7 @@ import type { MessageExtraInfo, NonCompleteResultFlow, NotificationMethod, + ProtocolEra, ProtocolOptions, ReadResourceRequest, ReadResourceResult, @@ -49,6 +50,8 @@ import type { UnsubscribeRequest } from '@modelcontextprotocol/core'; import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, @@ -61,6 +64,7 @@ import { mergeCapabilities, parseSchema, Protocol, + PROTOCOL_VERSION_META_KEY, ProtocolError, ProtocolErrorCode, resolveInputRequiredDriverConfig, @@ -165,8 +169,11 @@ export type ClientOptions = ProtocolOptions & { /** * Opt-in protocol version negotiation (protocol revision 2026-07-28 and later). * - * - absent or `mode: 'legacy'` — the plain 2025 connect sequence, byte-identical - * to today's behavior (no probe, no new headers). + * **The default is `'legacy'`**: absent (or `mode: 'legacy'`), `connect()` + * runs the plain 2025 sequence, byte-identical to today's behavior (no + * probe, no new headers). Opt into `'auto'` or pin to talk to a 2026-07-28 + * server. + * * - `mode: 'auto'` — `connect()` probes the server with `server/discover` first: * definitive modern evidence selects the modern era; definitive legacy signals * (and anything unrecognized) fall back to the plain legacy `initialize` @@ -179,8 +186,15 @@ export type ClientOptions = ProtocolOptions & { * - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision; * no probe-and-fallback: anything else fails loudly. * - * Probe policy lives under `probe: { timeoutMs? }`; the probe inherits the - * client's standard request timeout unless overridden. + * Probe policy lives under `probe: { timeoutMs?, maxRetries? }`; the probe + * inherits the client's standard request timeout unless overridden, and + * `maxRetries` (default `0`) governs timeout re-sends only — the + * spec-mandated `-32004` corrective continuation is never counted against it. + * + * Once a modern era is negotiated, the client automatically attaches the + * per-request `_meta` envelope (the reserved protocol-version / client-info / + * client-capabilities keys) to every outgoing request and notification; + * user-supplied `_meta` keys take precedence over the auto-attached ones. */ versionNegotiation?: VersionNegotiationOptions; @@ -334,6 +348,30 @@ export class Client extends Protocol { return undefined; } + /** + * Per-request `_meta` envelope auto-emission (protocol revision 2026-07-28): + * on a connection that negotiated a modern era — auto-negotiated or pinned — + * every outgoing request and notification automatically carries the reserved + * protocol-version / client-info / client-capabilities `_meta` keys (the + * same envelope the connect-time `server/discover` probe sends). + * User-supplied `_meta` keys take precedence over the auto-attached ones. + * + * Legacy-era connections return `undefined`: the envelope seam is a no-op + * and outbound traffic is byte-identical to a 2025 client (the legacy + * `'auto'` fallback included). + */ + protected override _outboundMetaEnvelope(): Readonly> | undefined { + const version = this._negotiatedProtocolVersion; + if (version === undefined || !isModernProtocolVersion(version)) { + return undefined; + } + return { + [PROTOCOL_VERSION_META_KEY]: version, + [CLIENT_INFO_META_KEY]: this._clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: this._capabilities + }; + } + /** * Wires the multi-round-trip auto-fulfilment engine (protocol revision * 2026-07-28) into the response funnel: an `input_required` answer is @@ -412,6 +450,21 @@ export class Client extends Protocol { this._capabilities = mergeCapabilities(this._capabilities, capabilities); } + /** + * Configure protocol version negotiation before connecting (equivalent to + * passing `versionNegotiation` at construction time). Can only be called + * before connecting to a transport. Passing `undefined` clears a previously + * configured negotiation, restoring the default `'legacy'` posture. + * + * See {@linkcode ClientOptions | ClientOptions.versionNegotiation} for the mode semantics. + */ + public setVersionNegotiation(options: VersionNegotiationOptions | undefined): void { + if (this.transport) { + throw new Error('Cannot configure version negotiation after connecting to transport'); + } + this._versionNegotiation = options; + } + /** * Enforces client-side validation for `elicitation/create` and `sampling/createMessage` * regardless of how the handler was registered. @@ -779,6 +832,19 @@ export class Client extends Protocol { return this._negotiatedProtocolVersion; } + /** + * After initialization has completed, this returns the protocol era of the + * connection: `'modern'` when the connection negotiated a 2026-07-28+ + * revision (via `server/discover`), `'legacy'` for the 2025-era + * `initialize` handshake, or `undefined` before the connection is + * established. + */ + getProtocolEra(): ProtocolEra | undefined { + const version = this._negotiatedProtocolVersion; + if (version === undefined) return undefined; + return isModernProtocolVersion(version) ? 'modern' : 'legacy'; + } + /** * After initialization has completed, this may be populated with information about the server's instructions. */ diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index f4b80511ca..710b4d399b 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -49,6 +49,17 @@ export interface VersionNegotiationProbeOptions { * @default the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`) */ timeoutMs?: number; + + /** + * Number of times to re-send the probe after a timeout before reaching the + * timeout verdict. Governs timeout re-sends only — the spec-mandated + * `-32004` corrective continuation (select-and-continue with a mutual + * version) is a separate negotiation step and is never counted against + * `maxRetries`. + * + * @default 0 (no retries) + */ + maxRetries?: number; } /** @@ -323,6 +334,7 @@ export async function negotiateEra( deps: NegotiationDeps ): Promise { const timeoutMs = negotiation.probe.timeoutMs ?? deps.defaultTimeoutMs; + const maxRetries = Math.max(0, negotiation.probe.maxRetries ?? 0); const clientModernVersions = negotiation.kind === 'pin' ? [negotiation.version] : negotiation.modernVersions; const fallbackAvailable = negotiation.kind === 'auto' && negotiation.fallbackAvailable; @@ -334,12 +346,20 @@ export async function negotiateEra( // mutual version equals the just-rejected one); the loop guard arms on // the second rejection. let correctiveUsed = false; + // `maxRetries` governs timeout re-sends only — independent of (and + // never counted against) the corrective continuation. + let timeoutRetriesRemaining = maxRetries; for (;;) { const reply = await window.exchange( id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), timeoutMs ); + if (reply.kind === 'timeout' && timeoutRetriesRemaining > 0) { + timeoutRetriesRemaining--; + continue; + } + const outcome = normalizeReply(reply, timeoutMs); const verdict: ProbeVerdict = classifyProbeOutcome(outcome, { clientModernVersions, diff --git a/packages/client/test/client/envelopeAutoEmission.test.ts b/packages/client/test/client/envelopeAutoEmission.test.ts new file mode 100644 index 0000000000..307baa1a9e --- /dev/null +++ b/packages/client/test/client/envelopeAutoEmission.test.ts @@ -0,0 +1,248 @@ +/** + * Per-request `_meta` envelope auto-emission (protocol revision 2026-07-28): + * on a connection that negotiated the modern era — auto-negotiated or pinned — + * the client automatically attaches the reserved protocol-version / + * client-info / client-capabilities `_meta` keys to every outgoing request and + * notification. User-supplied `_meta` keys win over the auto-attached ones. + * Legacy-era connections never gain these keys (D9b byte-identity holds). + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +const flush = () => new Promise(resolve => setTimeout(resolve, 20)); + +function metaOf(message: JSONRPCMessage): Record | undefined { + const params = (message as { params?: { _meta?: Record } }).params; + return params?._meta; +} + +/** + * A scripted server side of an in-memory pair: answers `server/discover` (so a + * negotiating client lands on the modern era) or `initialize` (legacy era), and + * records everything the client writes. + */ +async function scriptedServerSide(era: 'modern' | 'legacy', answerToolsList = true) { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const request = message as { id?: number | string; method?: string }; + if (request.method === 'server/discover' && request.id !== undefined) { + if (era === 'modern') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } + } + }); + } else { + void serverTx.send({ jsonrpc: '2.0', id: request.id, error: { code: -32_601, message: 'Method not found' } }); + } + return; + } + if (request.method === 'initialize' && request.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' } + } + }); + return; + } + if (request.method === 'tools/list' && request.id !== undefined && answerToolsList) { + const result: Record = + era === 'modern' ? { resultType: 'complete', tools: [], ttlMs: 0, cacheScope: 'public' } : { tools: [] }; + void serverTx.send({ jsonrpc: '2.0', id: request.id, result }); + } + }; + await serverTx.start(); + return { clientTx, written }; +} + +describe('per-request _meta envelope auto-emission on modern-era connections', () => { + it('attaches the reserved envelope keys to every outgoing request and notification', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const clientInfo = { name: 'envelope-client', version: '1.2.3' }; + const client = new Client(clientInfo, { + versionNegotiation: { mode: 'auto' }, + capabilities: { elicitation: { form: {} } } + }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('modern'); + + await client.listTools(); + await client.notification({ method: 'notifications/progress', params: { progressToken: 't', progress: 1 } }); + await flush(); + + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + expect(listToolsMessage).toBeDefined(); + expect(metaOf(listToolsMessage!)).toEqual({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } } + }); + + const progressMessage = written.find(m => (m as { method?: string }).method === 'notifications/progress'); + expect(progressMessage).toBeDefined(); + expect(metaOf(progressMessage!)?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + expect(metaOf(progressMessage!)?.[CLIENT_INFO_META_KEY]).toEqual(clientInfo); + + await client.close(); + }); + + it('reflects registered client capabilities in the auto-attached client-capabilities key', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + client.registerCapabilities({ sampling: {} }); + await client.connect(clientTx); + + await client.listTools(); + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + expect(metaOf(listToolsMessage!)?.[CLIENT_CAPABILITIES_META_KEY]).toEqual({ sampling: {} }); + + await client.close(); + }); + + it('user-supplied _meta keys win over the auto-attached envelope keys; non-envelope keys are preserved', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(clientTx); + + await client.request({ + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 'consumer-override', 'x-consumer': 'kept' } } + }); + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + const meta = metaOf(listToolsMessage!); + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBe('consumer-override'); + expect(meta?.['x-consumer']).toBe('kept'); + // The other envelope keys are still auto-attached. + expect(meta?.[CLIENT_INFO_META_KEY]).toEqual({ name: 'envelope-client', version: '1.0.0' }); + + await client.close(); + }); + + it('attaches the envelope to the cancellation notification of a modern-era request', async () => { + const { clientTx, written } = await scriptedServerSide('modern', /* answerToolsList */ false); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(clientTx); + + const controller = new AbortController(); + const pending = client.listTools(undefined, { signal: controller.signal }).catch(() => {}); + await flush(); + controller.abort('test cancel'); + await pending; + await flush(); + + const cancelMessage = written.find(m => (m as { method?: string }).method === 'notifications/cancelled'); + expect(cancelMessage).toBeDefined(); + expect(metaOf(cancelMessage!)?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + + await client.close(); + }); + + it('legacy-era connections never gain the envelope keys (byte-identity with a 2025 client)', async () => { + const { clientTx, written } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + + await client.listTools(); + await flush(); + + // initialize, notifications/initialized, tools/list — none carry envelope keys. + const postProbe = written.filter(m => (m as { method?: string }).method !== 'server/discover'); + expect(postProbe.length).toBeGreaterThanOrEqual(3); + for (const message of postProbe) { + const meta = metaOf(message); + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined(); + expect(meta?.[CLIENT_INFO_META_KEY]).toBeUndefined(); + expect(meta?.[CLIENT_CAPABILITIES_META_KEY]).toBeUndefined(); + } + + await client.close(); + }); + + it('the plain legacy default (no versionNegotiation) emits no envelope keys at all', async () => { + const { clientTx, written } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + + await client.listTools(); + await flush(); + + for (const message of written) { + const meta = metaOf(message); + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined(); + } + // initialize body matches today's plain client (no probe was ever sent). + expect(written.some(m => (m as { method?: string }).method === 'server/discover')).toBe(false); + + await client.close(); + }); +}); + +describe('setVersionNegotiation()', () => { + it('configures negotiation pre-connect (equivalent to the constructor option)', async () => { + const { clientTx } = await scriptedServerSide('modern'); + const client = new Client({ name: 'setter-client', version: '1.0.0' }); + client.setVersionNegotiation({ mode: { pin: MODERN } }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); + + it('throws after connecting to a transport', async () => { + const { clientTx } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'setter-client', version: '1.0.0' }); + await client.connect(clientTx); + expect(() => client.setVersionNegotiation({ mode: 'auto' })).toThrow(/after connecting/); + await client.close(); + }); + + it('passing undefined clears a previously configured negotiation (back to the legacy default)', async () => { + const { clientTx } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'setter-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + client.setVersionNegotiation(undefined); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); +}); + +describe('getProtocolEra()', () => { + it('is undefined before connect, "legacy" after a 2025 handshake, "modern" after a 2026-07-28 negotiation', async () => { + const legacy = await scriptedServerSide('legacy'); + const legacyClient = new Client({ name: 'era-client', version: '1.0.0' }); + expect(legacyClient.getProtocolEra()).toBeUndefined(); + await legacyClient.connect(legacy.clientTx); + expect(legacyClient.getProtocolEra()).toBe('legacy'); + await legacyClient.close(); + + const modern = await scriptedServerSide('modern'); + const modernClient = new Client({ name: 'era-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await modernClient.connect(modern.clientTx); + expect(modernClient.getProtocolEra()).toBe('modern'); + await modernClient.close(); + }); +}); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index a358ca0c30..9187fa141b 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -397,6 +397,44 @@ describe('probe timeout policy (transport-aware)', () => { ); expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); }); + + test('maxRetries (default 0) governs timeout re-sends only; the timeout verdict applies after retries are exhausted', async () => { + // HTTP-class: even with retries, a server that never answers produces a + // typed timeout error after maxRetries+1 probe sends — never a legacy verdict. + const transport = new ScriptedTransport(silentScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 20, maxRetries: 2 } } } + ); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + const probes = transport.sent.filter(m => 'method' in m && m.method === 'server/discover'); + expect(probes).toHaveLength(3); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('maxRetries: a server that answers on the first retry resolves normally (the retry budget is timeout-only)', async () => { + let discoverCalls = 0; + const slowThenFastScript: Script = (message, t) => { + if (!isJSONRPCRequest(message) || message.method !== 'server/discover') return; + discoverCalls++; + // Ignore the first probe (forces a timeout); answer the retry. + if (discoverCalls === 1) return; + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult([MODERN]) }); + }; + const transport = new ScriptedTransport(slowThenFastScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 20, maxRetries: 1 } } } + ); + + await client.connect(transport); + expect(discoverCalls).toBe(2); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); }); /* ------------------------------------------------------------------------- * diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 3257f6df2d..b5e3d29c3b 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -90,6 +90,9 @@ export { TRACESTATE_META_KEY } from '../../types/constants.js'; +// Protocol-era helpers +export type { ProtocolEra } from '../../shared/protocolEras.js'; + // Enums export { ProtocolErrorCode } from '../../types/enums.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d60bfc423a..873aa6f92a 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -569,6 +569,39 @@ export abstract class Protocol { return undefined; } + /** + * The per-request `_meta` envelope this instance attaches to every outgoing + * request and notification, when one applies. The base implementation + * returns `undefined` (no envelope — the 2025-era posture, so legacy-era + * outbound traffic is byte-identical to a build without this seam). + * `Client` overrides it on a connection that negotiated a modern (2026-07-28+) + * era to return the reserved protocol-version / client-info / + * client-capabilities keys. User-supplied `_meta` keys take precedence over + * the auto-attached ones. + */ + protected _outboundMetaEnvelope(): Readonly> | undefined { + return undefined; + } + + /** + * Attach this instance's outbound `_meta` envelope (when one is configured) + * to a request or notification. A no-op when the seam returns `undefined` + * — the message returns by reference, so the legacy-era wire stays + * byte-identical. User-supplied `_meta` keys are spread last so they win + * over the auto-attached envelope keys. + */ + private _envelopeOutbound(message: T): T { + const envelope = this._outboundMetaEnvelope(); + if (envelope === undefined) { + return message; + } + const params = (message.params ?? {}) as { _meta?: Record }; + return { + ...message, + params: { ...params, _meta: { ...envelope, ...params._meta } } + }; + } + /** * Extension point for non-`complete` decoded results in the response * funnel: a result the wire codec discriminated into a kind other than @@ -1262,6 +1295,12 @@ export abstract class Protocol { }; } + // Per-request envelope auto-attach (after the progressToken merge so + // both share the same `_meta`): a no-op on the legacy era — the + // envelope seam returns undefined and the request goes out exactly as + // built above. + const outbound = this._envelopeOutbound(jsonrpcRequest); + let responseReceived = false; const cancel = (reason: unknown) => { @@ -1272,14 +1311,14 @@ export abstract class Protocol { this._transport ?.send( - { + this._envelopeOutbound({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: messageId, reason: String(reason) } - }, + }), { relatedRequestId, resumptionToken, onresumptiontoken } ) .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); @@ -1367,7 +1406,7 @@ export abstract class Protocol { this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._transport.send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { this._progressHandlers.delete(messageId); reject(error); }); @@ -1415,7 +1454,7 @@ export abstract class Protocol { this.assertNotificationCapability(notification.method); - const jsonrpcNotification: JSONRPCNotification = { jsonrpc: '2.0', ...notification }; + const jsonrpcNotification = this._envelopeOutbound({ jsonrpc: '2.0' as const, ...notification }); const debouncedMethods = this._options?.debouncedNotificationMethods ?? []; // A notification can only be debounced if it's in the list AND it's "simple" diff --git a/packages/core/src/shared/protocolEras.ts b/packages/core/src/shared/protocolEras.ts index a85135fa06..bfe85242e2 100644 --- a/packages/core/src/shared/protocolEras.ts +++ b/packages/core/src/shared/protocolEras.ts @@ -11,6 +11,13 @@ * modern revisions. */ +/** + * The protocol era of a connection: `'legacy'` for the 2025-11-25 family and + * earlier (negotiated via `initialize`), `'modern'` for 2026-07-28 and later + * (negotiated via `server/discover`; every request carries a `_meta` envelope). + */ +export type ProtocolEra = 'legacy' | 'modern'; + /** * The first protocol revision of the modern (2026-07-28) era. Revision identifiers * are ISO dates, so lexicographic comparison orders them chronologically. diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index a619678bec..3b61675c96 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -14,12 +14,9 @@ import { Client, - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, ClientCredentialsProvider, CrossAppAccessProvider, PrivateKeyJwtProvider, - PROTOCOL_VERSION_META_KEY, requestJwtAuthorizationGrant, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; @@ -118,19 +115,6 @@ function isModernConformanceRun(): boolean { return version !== undefined && MODERN_SPEC_VERSIONS.has(version); } -/** - * The per-request `_meta` envelope every 2026-era request carries on the wire. - * Automatic envelope emission is not implemented in the client yet (it is a - * client-side follow-up), so modern-era requests attach it explicitly. - */ -function modernEnvelope(clientInfo: { name: string; version: string }, capabilities: object, protocolVersion: string | undefined) { - return { - [PROTOCOL_VERSION_META_KEY]: protocolVersion ?? '2026-07-28', - [CLIENT_INFO_META_KEY]: clientInfo, - [CLIENT_CAPABILITIES_META_KEY]: capabilities - }; -} - // ============================================================================ // Basic scenarios (initialize, tools_call) // ============================================================================ @@ -181,27 +165,25 @@ async function runToolsCallClient(serverUrl: string): Promise { } // tools_call under a 2026-07-28 run: negotiate the modern era via -// server/discover (versionNegotiation), then drive the same tool flow with -// the per-request _meta envelope attached to every request. +// server/discover (versionNegotiation), then drive the same tool flow — the +// client attaches the per-request _meta envelope to every request itself. async function runToolsCallModernClient(serverUrl: string): Promise { - const clientInfo = { name: 'test-client', version: '1.0.0' }; - const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); await client.connect(transport); logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); - const envelope = modernEnvelope(clientInfo, {}, client.getNegotiatedProtocolVersion()); - const tools = await client.request({ method: 'tools/list', params: { _meta: envelope } }); + const tools = await client.listTools(); logger.debug('Successfully listed tools'); // Call the add_numbers tool const addTool = tools.tools.find(t => t.name === 'add_numbers'); if (addTool) { - const result = await client.request({ - method: 'tools/call', - params: { name: 'add_numbers', arguments: { a: 5, b: 3 }, _meta: envelope } + const result = await client.callTool({ + name: 'add_numbers', + arguments: { a: 5, b: 3 } }); logger.debug('Tool call result:', JSON.stringify(result, null, 2)); } @@ -302,21 +284,19 @@ async function runMrtrClient(serverUrl: string): Promise { await client.connect(transport); logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); - const envelope = modernEnvelope(clientInfo, capabilities, client.getNegotiatedProtocolVersion()); - // requestState echo flow: the driver must echo the opaque state byte-exact // and retry on a fresh JSON-RPC id. - const echoResult = await client.callTool({ name: 'test_mrtr_echo_state', arguments: {}, _meta: envelope }); + const echoResult = await client.callTool({ name: 'test_mrtr_echo_state', arguments: {} }); logger.debug('test_mrtr_echo_state result:', JSON.stringify(echoResult)); // No-state flow: the InputRequiredResult carries no requestState, so the // retry must not include one. - const noStateResult = await client.callTool({ name: 'test_mrtr_no_state', arguments: {}, _meta: envelope }); + const noStateResult = await client.callTool({ name: 'test_mrtr_no_state', arguments: {} }); logger.debug('test_mrtr_no_state result:', JSON.stringify(noStateResult)); // Unrelated call: must not carry inputResponses or requestState from the // multi-round-trip flows above. - const unrelatedResult = await client.callTool({ name: 'test_mrtr_unrelated', arguments: {}, _meta: envelope }); + const unrelatedResult = await client.callTool({ name: 'test_mrtr_unrelated', arguments: {} }); logger.debug('test_mrtr_unrelated result:', JSON.stringify(unrelatedResult)); // Result without resultType: the check passes as long as the client does @@ -324,7 +304,7 @@ async function runMrtrClient(serverUrl: string): Promise { // a 2026-negotiated server as a protocol violation and rejects locally // without retrying, so this call is expected to throw. try { - const noResultTypeResult = await client.callTool({ name: 'test_mrtr_no_result_type', arguments: {}, _meta: envelope }); + const noResultTypeResult = await client.callTool({ name: 'test_mrtr_no_result_type', arguments: {} }); logger.debug('test_mrtr_no_result_type result:', JSON.stringify(noResultTypeResult)); } catch (error) { logger.debug('test_mrtr_no_result_type rejected locally (no retry):', error instanceof Error ? error.message : String(error)); diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md index 3e16961b60..d44ba8a03a 100644 --- a/test/e2e/CLAUDE.md +++ b/test/e2e/CLAUDE.md @@ -63,8 +63,8 @@ entry points back via `supersededBy` (requires `removedInSpecVersion`). A covera Two transport arms host the dual-era HTTP entry (`createMcpHandler`) in process via an injected fetch, exactly like the other HTTP arms. They are era-fixed (`TRANSPORT_SPEC_VERSIONS`), so each registers cells on exactly one spec-version axis: - `entryStateless` — the entry with its stateless legacy fallback (`legacy: 'stateless'`, the entry's default posture, passed explicitly so the arm stays era-pinned); the scenario's plain client is served per request through the fallback. Cells run on the 2025-11-25 axis only. -- `entryModern` — the entry hosted modern-only strict (`legacy: 'reject'`); the scenario's client is put into pinned 2026-07-28 negotiation by the arm and the per-request `_meta` envelope is attached to every outgoing request/notification by the arm (a harness stop-gap until the - client emits it itself). Cells run on the 2026-07-28 axis only. +- `entryModern` — the entry hosted modern-only strict (`legacy: 'reject'`); the arm pins the scenario's client to the 2026-07-28 revision via `setVersionNegotiation()`, and the client attaches the per-request `_meta` envelope to every outgoing request/notification itself. Cells + run on the 2026-07-28 axis only. The pin is unconditional, so a scenario that needs to assert non-pin negotiation behavior (e.g. `mode: 'auto'` probing) must restrict off `entryModern` or drive a non-entry transport. Both arms are part of the default transport list, so unrestricted requirements run through the entry automatically. When a requirement cannot run on an entry arm, annotate it with a machine-readable reason instead of bending the test: diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index ce4a19e93e..85b6ca5f6d 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -23,8 +23,7 @@ import type { JSONRPCMessage, McpRequestContext, McpServer, - Server, - Transport as SdkTransport + Server } from '@modelcontextprotocol/server'; import { createMcpHandler, @@ -140,9 +139,11 @@ export async function wire( // client through the entry's stateless legacy fallback (the default, // passed explicitly to keep the arm era-pinned); `entryModern` hosts the // endpoint modern-only strict (`legacy: 'reject'` — strict is no longer - // the entry default) and connects the client on the 2026-07-28 revision - // (pin-mode negotiation + the per-request envelope stop-gap). Every HTTP - // exchange is recorded on `httpLog`. + // the entry default) and pins the scenario's client to the 2026-07-28 + // revision via the public negotiation setter. The client attaches the + // per-request `_meta` envelope itself once a modern era is negotiated, + // so no harness wrap is needed. Every HTTP exchange is recorded on + // `httpLog`. const handler = createMcpHandler( makeServer, transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { legacy: 'reject', ...sniff.entry } @@ -161,14 +162,13 @@ export async function wire( }); return response; }; - let clientTx = new StreamableHTTPClientTransport(url, { fetch }); + const clientTx = new StreamableHTTPClientTransport(url, { fetch }); // entryModern is the era-fixed 2026-07-28 arm: it is the only arm // whose wire may legitimately carry input_required results, so it // opts the sniffer into accepting them (other arms stay strict). let armSniff: WireOptions = sniff; if (transport === 'entryModern') { - pinModernNegotiation(client); - clientTx = attachModernEnvelope(clientTx); + client.setVersionNegotiation({ mode: { pin: MODERN_REVISION } }); armSniff = { allowInputRequiredResults: true, ...sniff }; } await client.connect(sniffTransport(clientTx, 'client', armSniff)); @@ -336,8 +336,8 @@ const MODERN_REVISION: SpecVersion = '2026-07-28'; * The per-request `_meta` envelope of a 2026-07-28 request, for scenario bodies * that put raw HTTP requests on the wire (via `wired.fetch`) rather than going * through the wired client. Typed calls through the wired client never need - * this — the entryModern arm attaches the envelope itself (see - * {@linkcode attachModernEnvelope}). + * this — the client attaches the envelope itself once a modern era is + * negotiated. */ export function modernEnvelopeMeta(clientInfo?: Implementation): Record { return { @@ -347,19 +347,6 @@ export function modernEnvelopeMeta(clientInfo?: Implementation): Record(transport: T): T { - let envelope: Record | undefined; - const origSend = transport.send.bind(transport); - transport.send = async (message, opts) => { - let outbound = message; - if ('method' in message) { - const params = (message.params ?? {}) as { _meta?: Record }; - const meta = params._meta; - if (meta?.[PROTOCOL_VERSION_META_KEY] !== undefined) { - envelope = { - [PROTOCOL_VERSION_META_KEY]: meta[PROTOCOL_VERSION_META_KEY], - [CLIENT_INFO_META_KEY]: meta[CLIENT_INFO_META_KEY], - [CLIENT_CAPABILITIES_META_KEY]: meta[CLIENT_CAPABILITIES_META_KEY] - }; - } else if (envelope !== undefined) { - outbound = { ...message, params: { ...params, _meta: { ...envelope, ...meta } } }; - } - } - return origSend(outbound, opts); - }; - return transport; -} - // ─────────────────────────────────────────────────────────────────────────────── // In-process stdio client — TEST-ONLY // diff --git a/test/e2e/scenarios/hosting-entry-stamping.test.ts b/test/e2e/scenarios/hosting-entry-stamping.test.ts index 6ef259ba16..6cf4c51078 100644 --- a/test/e2e/scenarios/hosting-entry-stamping.test.ts +++ b/test/e2e/scenarios/hosting-entry-stamping.test.ts @@ -98,7 +98,7 @@ function wireResultWith(bodies: string[], key: string): Record } verifies('typescript:hosting:entry:modern-cacheable-stamping', async ({ transport }: TestArgs) => { - const client = new Client({ name: 'e2e-stamping-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const client = new Client({ name: 'e2e-stamping-client', version: '1.0.0' }); await using wired = await wire(transport, cacheConfiguredFactory, client); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); diff --git a/test/e2e/scenarios/hosting-entry-streaming.test.ts b/test/e2e/scenarios/hosting-entry-streaming.test.ts index 6b6ec0c0cd..0f0360af09 100644 --- a/test/e2e/scenarios/hosting-entry-streaming.test.ts +++ b/test/e2e/scenarios/hosting-entry-streaming.test.ts @@ -11,8 +11,9 @@ * - `responseMode: 'json'` never streams and drops mid-call notifications — * only the terminal result is delivered. * - * Every body drives the harness-hosted entry with the auto-negotiating client; - * the typed result and the raw wire bytes (status, content-type, SSE frames) + * Every body drives the harness-hosted entry through the wired client (the + * entryModern arm pins it to 2026-07-28); the typed result and the raw wire + * bytes (status, content-type, SSE frames) * are asserted side by side via the arm-recorded `wired.httpLog`. */ import { Client } from '@modelcontextprotocol/client'; @@ -71,8 +72,8 @@ function sseDataFrames(body: string): Array> { .map(line => JSON.parse(line.slice('data: '.length)) as Record); } -function newAutoClient(): Client { - return new Client({ name: 'e2e-streaming-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +function newClient(): Client { + return new Client({ name: 'e2e-streaming-client', version: '1.0.0' }); } function callTool(client: Client, name: 'quiet' | 'chatty'): Promise { @@ -80,7 +81,7 @@ function callTool(client: Client, name: 'quiet' | 'chatty'): Promise { - const client = newAutoClient(); + const client = newClient(); await using wired = await wire(transport, streamingFactory, client); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); @@ -116,7 +117,7 @@ verifies('typescript:hosting:entry:modern-response-mode', async ({ transport }: // responseMode 'sse': even a handler that emits nothing streams its result. { - const client = newAutoClient(); + const client = newClient(); await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'sse' } }); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); @@ -136,7 +137,7 @@ verifies('typescript:hosting:entry:modern-response-mode', async ({ transport }: // responseMode 'json': mid-call notifications are dropped — the response // is a plain JSON body whose only payload is the terminal result. { - const client = newAutoClient(); + const client = newClient(); await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'json' } }); expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts index 1958a59d0f..12e82aad5e 100644 --- a/test/e2e/scenarios/hosting-entry.test.ts +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -33,11 +33,9 @@ function greetFactory(ctx?: McpRequestContext): McpServer { verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: TestArgs) => { // Both cells host the same handler shape — one ctx-taking factory, the - // 'stateless' legacy posture — and differ only in the client driving it. - const client = - transport === 'entryModern' - ? new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }) - : new Client({ name: 'plain-2025-client', version: '1.0.0' }); + // 'stateless' legacy posture — driven by a plain client; the entry arm + // decides which era serves it (entryModern pins the client to 2026-07-28). + const client = new Client({ name: 'dual-era-client', version: '1.0.0' }); await using wired = await wire(transport, greetFactory, client, { entry: { legacy: 'stateless' } }); if (transport === 'entryStateless') { @@ -51,7 +49,7 @@ verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: return; } - // 2026-era leg: the auto-negotiating client reaches 2026-07-28 via + // 2026-era leg: the arm-pinned client reaches 2026-07-28 via // server/discover — never initialize — and tools/call is served with the // per-request envelope (the modern factory leg answers, not the slot). expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); diff --git a/test/e2e/scenarios/mrtr.test.ts b/test/e2e/scenarios/mrtr.test.ts index 5899a4bafd..3e45e30263 100644 --- a/test/e2e/scenarios/mrtr.test.ts +++ b/test/e2e/scenarios/mrtr.test.ts @@ -61,10 +61,7 @@ verifies('typescript:mrtr:tools-call:write-once-roundtrip', async ({ transport } return server; }; - const client = new Client( - { name: 'mrtr-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } - ); + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); const handled: unknown[] = []; client.setRequestHandler('elicitation/create', async request => { handled.push(request.params); @@ -104,10 +101,7 @@ verifies('typescript:mrtr:push-api:loud-fail-2026', async ({ transport }: TestAr return server; }; - const client = new Client( - { name: 'mrtr-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } - ); + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: {} })); await using wired = await wire(transport, makeServer, client); @@ -143,10 +137,7 @@ verifies('typescript:mrtr:url-elicitation:no-32042-on-2026', async ({ transport return server; }; - const client = new Client( - { name: 'mrtr-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { url: {} } } } - ); + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { url: {} } } }); const seenUrlRequests: unknown[] = []; client.setRequestHandler('elicitation/create', async request => { seenUrlRequests.push(request.params); @@ -183,7 +174,7 @@ verifies('typescript:mrtr:rounds-cap', async ({ transport }: TestArgs) => { const client = new Client( { name: 'mrtr-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } }, inputRequired: { maxRounds: 2 } } + { capabilities: { elicitation: { form: {} } }, inputRequired: { maxRounds: 2 } } ); client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { confirm: true } })); diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 4cb0406763..75df591691 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -365,8 +365,11 @@ verifies('protocol:error:invalid-params', async ({ transport }: TestArgs) => { await expect(call).rejects.toBeInstanceOf(ProtocolError); // The malformed request did reach the wire (failure is server-side, not client-side validation). + // toMatchObject: on a 2026-07-28 connection the client auto-attaches the per-request `_meta` + // envelope, which is additive and not part of the assertion's intent. const sent = outbound.filter(m => isRequest(m)).find(m => m.method === 'tools/call'); - expect(sent?.params).toEqual({ arguments: {} }); + expect(sent?.params).toMatchObject({ arguments: {} }); + expect((sent?.params as { name?: unknown }).name).toBeUndefined(); expect(ProtocolErrorCode.InvalidParams).toBe(-32_602); await expect(call).rejects.toMatchObject({ code: ProtocolErrorCode.InvalidParams }); diff --git a/test/e2e/scenarios/stdio-dual-era.test.ts b/test/e2e/scenarios/stdio-dual-era.test.ts index 319e85cf4c..503540454c 100644 --- a/test/e2e/scenarios/stdio-dual-era.test.ts +++ b/test/e2e/scenarios/stdio-dual-era.test.ts @@ -17,8 +17,6 @@ import { fileURLToPath } from 'node:url'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; -import type { CallToolResult } from '@modelcontextprotocol/server'; import { expect } from 'vitest'; import { verifies } from '../helpers/verifies.js'; @@ -30,8 +28,6 @@ const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/dual-era-stdio-server.ts /** E2E package root — spawn cwd so node/tsx resolve the local toolchain and workspace packages. */ const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url)); -const MODERN = '2026-07-28'; - verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion }: TestArgs) => { const transport = new StdioClientTransport({ command: process.execPath, @@ -75,15 +71,7 @@ verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion expect(sentMethods).not.toContain('initialize'); expect(sentMethods[0]).toBe('server/discover'); - const envelope = { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'auto-client', version: '0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; - const result = (await client.request({ - method: 'tools/call', - params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope } - })) as CallToolResult; + const result = await client.callTool({ name: 'echo', arguments: { text: 'modern leg' } }); expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); } finally { await client.close(); diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index e1b2693667..fc3619277e 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -9,7 +9,7 @@ import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; -import type { CallToolResult, CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import type { CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import { afterEach, describe, expect, it } from 'vitest'; @@ -47,14 +47,6 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { return { baseUrl, handler }; } - function modernEnvelope() { - return { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'integration-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; - } - it('serves the modern era to an auto-negotiating client (default endpoint)', async () => { const { baseUrl } = await startEndpoint(); @@ -66,13 +58,7 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { expect(client.getServerVersion()).toEqual({ name: 'dual-era-endpoint', version: '1.0.0' }); expect(client.getInstructions()).toBe('dual era endpoint'); - // A typed tools/call round trip; the per-request envelope is attached - // explicitly here (automatic envelope emission for every modern request - // is a client-side follow-up). - const result = (await client.request({ - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'modern' }, _meta: modernEnvelope() } - })) as CallToolResult; + const result = await client.callTool({ name: 'greet', arguments: { name: 'modern' } }); expect(result.content).toEqual([{ type: 'text', text: 'hello modern (modern)' }]); }); @@ -100,10 +86,7 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { cleanups.push(() => modernClient.close()); expect(modernClient.getNegotiatedProtocolVersion()).toBe(MODERN); - const modernResult = (await modernClient.request({ - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'new friend' }, _meta: modernEnvelope() } - })) as CallToolResult; + const modernResult = await modernClient.callTool({ name: 'greet', arguments: { name: 'new friend' } }); expect(modernResult.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); }); @@ -140,7 +123,11 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { params: { name: 'greet', arguments: { name: 'x' }, - _meta: { ...modernEnvelope(), [PROTOCOL_VERSION_META_KEY]: '2030-01-01' } + _meta: { + [PROTOCOL_VERSION_META_KEY]: '2030-01-01', + [CLIENT_INFO_META_KEY]: { name: 'integration-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } } }) }); diff --git a/test/integration/test/server/dualEraStdio.test.ts b/test/integration/test/server/dualEraStdio.test.ts index 10a32d20f2..ff74afdfd9 100644 --- a/test/integration/test/server/dualEraStdio.test.ts +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -143,24 +143,21 @@ describe('serveStdio over a real child-process pipe (one connection per spawned expect(outbound.some(message => (message as { method?: string }).method === 'initialize')).toBe(false); expect((outbound[0] as { method?: string }).method).toBe('server/discover'); - // Modern vertical: list → call, every request carrying the per-request envelope. - // (Attaching it explicitly is the documented stop-gap until automatic - // per-request envelope emission lands client-side.) - const envelope = modernEnvelope('modern-pipe-client'); + // Modern vertical: list → call. The raw list carries a hand-built + // envelope so the resultType marker can be read on the wire; the + // typed call goes through the client, which attaches the envelope + // itself on the modern-negotiated connection. const modernList = await rawRequest(transport, inbound, { jsonrpc: '2.0', id: 'raw-modern-list', method: 'tools/list', - params: { _meta: envelope } + params: { _meta: modernEnvelope('modern-pipe-client') } }); const modernListResult = (modernList as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; expect(modernListResult?.tools?.map(tool => tool.name)).toEqual(['echo']); expect(modernListResult?.resultType).toBe('complete'); - const result = await client.request({ - method: 'tools/call', - params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope } - }); + const result = await client.callTool({ name: 'echo', arguments: { text: 'modern leg' } }); expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); // The connection is pinned to the 2026 era: a late claim-less From 0ff1bd02daf3391fc070ba1bf8756591ac2eb8e2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:36:01 +0100 Subject: [PATCH 29/37] =?UTF-8?q?feat(server):=20subscriptions/listen=20?= =?UTF-8?q?=E2=80=94=20entry-handled=20router=20and=20ServerEventBus=20(#2?= =?UTF-8?q?321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/subscriptions-listen-server.md | 17 + .../codemod/src/generated/specSchemaMap.ts | 5 + packages/core/src/exports/public/index.ts | 1 + .../core/src/shared/inboundClassification.ts | 56 ++- packages/core/src/types/constants.ts | 13 + packages/core/src/types/schemas.ts | 63 +++ packages/core/src/types/specTypeSchema.ts | 5 + packages/core/src/types/types.ts | 17 + .../src/wire/rev2026-07-28/encodeContract.ts | 6 +- .../core/src/wire/rev2026-07-28/registry.ts | 8 +- .../core/src/wire/rev2026-07-28/schemas.ts | 43 +- packages/core/test/corpus/specCorpus.test.ts | 4 +- .../core/test/spec.types.2026-07-28.test.ts | 79 +++- .../core/test/wire/registryDiffOracle.test.ts | 9 +- packages/server/src/index.ts | 3 + .../server/src/server/createMcpHandler.ts | 127 ++++-- packages/server/src/server/listenRouter.ts | 382 ++++++++++++++++++ packages/server/src/server/serveStdio.ts | 123 +++++- packages/server/src/server/server.ts | 28 +- packages/server/src/server/serverEventBus.ts | 194 +++++++++ .../server/createMcpHandlerListen.test.ts | 221 ++++++++++ packages/server/test/server/discover.test.ts | 23 +- .../test/server/serveStdioListen.test.ts | 240 +++++++++++ .../server/test/server/serverEventBus.test.ts | 151 +++++++ .../expected-failures.2026-07-28.yaml | 4 + test/conformance/expected-failures.yaml | 8 + .../test/client/discoverRoundtrip.test.ts | 5 +- .../test/server/createMcpHandler.test.ts | 74 +++- 28 files changed, 1772 insertions(+), 137 deletions(-) create mode 100644 .changeset/subscriptions-listen-server.md create mode 100644 packages/server/src/server/listenRouter.ts create mode 100644 packages/server/src/server/serverEventBus.ts create mode 100644 packages/server/test/server/createMcpHandlerListen.test.ts create mode 100644 packages/server/test/server/serveStdioListen.test.ts create mode 100644 packages/server/test/server/serverEventBus.test.ts diff --git a/.changeset/subscriptions-listen-server.md b/.changeset/subscriptions-listen-server.md new file mode 100644 index 0000000000..abaf0d6261 --- /dev/null +++ b/.changeset/subscriptions-listen-server.md @@ -0,0 +1,17 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +`subscriptions/listen` (SEP-1865) is served by both serving entries on protocol revision 2026-07-28. The entry owns ack-first, per-stream filtering, subscription-id stamping, keepalive (HTTP), the pre-ack `-32603` capacity guard, and teardown (HTTP stream close; one +`notifications/cancelled` per subscription on stdio). `server/discover` now advertises `listChanged`/`subscribe` capability bits — the rider that suppressed them until listen was served is discharged. + +Under `createMcpHandler` the consumer's factory **is** constructed for `subscriptions/listen` (a capabilities-only probe so the acknowledged filter reflects what the server advertises; the instance is never connected and is closed immediately). Per-request authorization performed inside the factory therefore sees listen requests; token verification still belongs at the middleware layer mounted in front of the entry. + +New public surface: + +- `@modelcontextprotocol/server`: `ServerEventBus`, `ServerEvent`, `ServerNotifier` (types); `InMemoryServerEventBus` (class). +- `McpHttpHandler` gains `.notify` (`ServerNotifier`: `toolsChanged()`, `promptsChanged()`, `resourcesChanged()`, `resourceUpdated(uri)`) and `.bus` (the `ServerEventBus` listen streams subscribe to). +- `CreateMcpHandlerOptions` gains `bus?: ServerEventBus` (an in-process `InMemoryServerEventBus` is created when omitted), `maxSubscriptions?: number` (default 1024), and `keepAliveMs?: number` (default 15000). +- `ServeStdioOptions` gains `maxSubscriptions?: number` (default 1024). On a modern-pinned connection `serveStdio` routes the pinned instance's existing `send*ListChanged()` calls onto active subscriptions; legacy connections are unchanged. +- `@modelcontextprotocol/core`: `SUBSCRIPTION_ID_META_KEY` (const); `SubscriptionFilter`, `SubscriptionsListenRequest`, `SubscriptionsListenRequestParams`, `SubscriptionsAcknowledgedNotification`, `SubscriptionsAcknowledgedNotificationParams` (types). diff --git a/packages/codemod/src/generated/specSchemaMap.ts b/packages/codemod/src/generated/specSchemaMap.ts index 99d8f84dfb..86d4c8435f 100644 --- a/packages/codemod/src/generated/specSchemaMap.ts +++ b/packages/codemod/src/generated/specSchemaMap.ts @@ -133,6 +133,11 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'StringSchemaSchema', 'SubscribeRequestParamsSchema', 'SubscribeRequestSchema', + 'SubscriptionFilterSchema', + 'SubscriptionsAcknowledgedNotificationParamsSchema', + 'SubscriptionsAcknowledgedNotificationSchema', + 'SubscriptionsListenRequestParamsSchema', + 'SubscriptionsListenRequestSchema', 'TaskAugmentedRequestParamsSchema', 'TaskMetadataSchema', 'TextContentSchema', diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index b5e3d29c3b..5caad4b89e 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -85,6 +85,7 @@ export { PARSE_ERROR, PROTOCOL_VERSION_META_KEY, RELATED_TASK_META_KEY, + SUBSCRIPTION_ID_META_KEY, SUPPORTED_PROTOCOL_VERSIONS, TRACEPARENT_META_KEY, TRACESTATE_META_KEY diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 88b62e06cf..915eebf0b3 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -67,7 +67,7 @@ import { PROTOCOL_VERSION_META_KEY } from '../types/constants.js'; import { ProtocolErrorCode } from '../types/enums.js'; import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors.js'; import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js'; -import type { MessageClassification } from '../types/types.js'; +import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '../types/types.js'; import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js'; import { isModernProtocolVersion } from './protocolEras.js'; @@ -126,17 +126,32 @@ export interface InboundLegacyRoute { requestedVersion?: string; } -/** The request claims the per-request envelope mechanism and is served on the modern path. */ -export interface InboundModernRoute { - kind: 'modern'; - /** Whether the classified message is a request or a notification. */ - messageKind: 'request' | 'notification'; - /** - * The classification handed to the per-request transport and validated by - * the protocol layer against the serving instance's negotiated era. - */ - classification: MessageClassification; -} +/** + * The request claims the per-request envelope mechanism and is served on the + * modern path. Discriminated by `messageKind` so the typed `message` narrows + * with it — the classifier has already proved the JSON-RPC shape via the + * `isJSONRPCRequest` / `isJSONRPCNotification` guards, so consumers never + * cast the body again. + */ +export type InboundModernRoute = + | { + kind: 'modern'; + messageKind: 'request'; + /** The classified body — guard-proved {@linkcode JSONRPCRequest} shape. */ + message: JSONRPCRequest; + /** + * The classification handed to the per-request transport and validated by + * the protocol layer against the serving instance's negotiated era. + */ + classification: MessageClassification; + } + | { + kind: 'modern'; + messageKind: 'notification'; + /** The classified body — guard-proved {@linkcode JSONRPCNotification} shape. */ + message: JSONRPCNotification; + classification: MessageClassification; + }; /** The named steps of the inbound validation ladder, in evaluation order. */ export type InboundValidationRung = @@ -461,9 +476,9 @@ function classifyBatch(body: readonly unknown[]): InboundClassificationOutcome { return { kind: 'legacy', reason: 'batch' }; } -function classifyRequestBody(request: InboundHttpRequest, body: Record): InboundClassificationOutcome { - const params = body['params']; - const method = body['method'] as string; +function classifyRequestBody(request: InboundHttpRequest, body: JSONRPCRequest): InboundClassificationOutcome { + const params = body.params; + const method = body.method; const headerVersion = request.protocolVersionHeader; const headerNamesModern = headerVersion !== undefined && isModernProtocolVersion(headerVersion); @@ -523,7 +538,7 @@ function classifyRequestBody(request: InboundHttpRequest, body: Record): InboundClassificationOutcome { - const params = body['params']; - const method = body['method'] as string; +function classifyNotificationBody(request: InboundHttpRequest, body: JSONRPCNotification): InboundClassificationOutcome { + const params = body.params; + const method = body.method; const headerVersion = request.protocolVersionHeader; const headerNamesModern = headerVersion !== undefined && isModernProtocolVersion(headerVersion); @@ -603,7 +618,7 @@ function classifyNotificationBody(request: InboundHttpRequest, body: Record; export type ResourceUpdatedNotificationParams = Infer; export type ResourceUpdatedNotification = Infer; +/* Subscriptions (protocol revision 2026-07-28) */ +export type SubscriptionFilter = Infer; +export type SubscriptionsListenRequestParams = Infer; +export type SubscriptionsListenRequest = Infer; +export type SubscriptionsAcknowledgedNotificationParams = Infer; +export type SubscriptionsAcknowledgedNotification = Infer; + /* Prompts */ export type PromptArgument = Infer; export type Prompt = Infer; @@ -551,6 +563,11 @@ export type ResultTypeMap = { 'resources/read': ReadResourceResult; 'resources/subscribe': EmptyResult; 'resources/unsubscribe': EmptyResult; + // `subscriptions/listen` never receives a JSON-RPC result on the wire: + // termination is stream close (HTTP) or `notifications/cancelled` (stdio). + // The `EmptyResult` entry exists only to keep the mapped types total — + // see the serving entries' listen routers. + 'subscriptions/listen': EmptyResult; 'tools/call': CallToolResult; 'tools/list': ListToolsResult; 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools; diff --git a/packages/core/src/wire/rev2026-07-28/encodeContract.ts b/packages/core/src/wire/rev2026-07-28/encodeContract.ts index d03613aeeb..de8f09a951 100644 --- a/packages/core/src/wire/rev2026-07-28/encodeContract.ts +++ b/packages/core/src/wire/rev2026-07-28/encodeContract.ts @@ -41,9 +41,9 @@ export const DEFAULT_CACHE_SCOPE = 'private'; * Request methods whose spec result vocabulary goes beyond `'complete'` on the * 2026-07-28 revision: their results may be `input_required` (multi * round-trip requests), so a handler-provided `resultType` passes through the - * stamp untouched. `subscriptions/listen` joins this set when the - * subscriptions feature is served (its terminal result uses the same - * mechanism). + * stamp untouched. `subscriptions/listen` is NOT in this set: it never emits + * a JSON-RPC result — termination is stream close (HTTP) or + * `notifications/cancelled` (stdio) per the spec. */ export const EXTENDED_RESULT_TYPE_METHODS: readonly string[] = ['tools/call', 'prompts/get', 'resources/read']; diff --git a/packages/core/src/wire/rev2026-07-28/registry.ts b/packages/core/src/wire/rev2026-07-28/registry.ts index bad5190407..49df49429f 100644 --- a/packages/core/src/wire/rev2026-07-28/registry.ts +++ b/packages/core/src/wire/rev2026-07-28/registry.ts @@ -20,10 +20,10 @@ * (the ATK-D flavor-b trap); this hand registry excludes them by * construction. Their in-band role lands with the MRTR driver (#13). * - `subscriptions/listen` + `notifications/subscriptions/acknowledged` - * (SEP-1865): 2026-only vocabulary whose SHELLS land with the - * subscriptions feature (#14). Until then they are absent here — inbound - * listen gets −32601 (capability not yet served), which is protocol-legal - * for a server that does not implement subscriptions. + * (SEP-1865): 2026-only vocabulary, present here as registry shells. + * Dispatch never reaches a registered handler — the serving entries + * (`createMcpHandler`, `serveStdio`) recognize listen at the entry layer + * and own ack/filter/stamp/teardown themselves. */ import type * as z from 'zod/v4'; diff --git a/packages/core/src/wire/rev2026-07-28/schemas.ts b/packages/core/src/wire/rev2026-07-28/schemas.ts index e7e68c8e1c..df91718a6d 100644 --- a/packages/core/src/wire/rev2026-07-28/schemas.ts +++ b/packages/core/src/wire/rev2026-07-28/schemas.ts @@ -460,6 +460,16 @@ const completeParamsShape = { export const CompleteRequestSchema = wireRequest('completion/complete', completeParamsShape); export const DiscoverRequestSchema = wireRequest('server/discover', {}); +/** Anchor SubscriptionFilter (2026-only). */ +export const SubscriptionFilterSchema = z.object({ + toolsListChanged: z.boolean().optional(), + promptsListChanged: z.boolean().optional(), + resourcesListChanged: z.boolean().optional(), + resourceSubscriptions: z.array(z.string()).optional() +}); +const subscriptionsListenParamsShape = { notifications: SubscriptionFilterSchema }; +export const SubscriptionsListenRequestSchema = wireRequest('subscriptions/listen', subscriptionsListenParamsShape); + /** * The 2026-era request-method set — the hand-registry seed (see registry.ts * for the seed decisions). The dispatch maps below are mapped types over this @@ -476,7 +486,8 @@ export type Rev2026RequestMethod = | 'resources/templates/list' | 'resources/read' | 'completion/complete' - | 'server/discover'; + | 'server/discover' + | 'subscriptions/listen'; /** Dispatch (post-lift) request schemas, keyed by method — registry-internal. */ export const dispatchRequestSchemas: { readonly [M in Rev2026RequestMethod]: z.ZodType<{ method: M }> } = { @@ -491,7 +502,8 @@ export const dispatchRequestSchemas: { readonly [M in Rev2026RequestMethod]: z.Z 'resources/templates/list': dispatchRequest('resources/templates/list', paginatedParamsShape), 'resources/read': dispatchRequest('resources/read', { uri: z.string() }), 'completion/complete': dispatchRequest('completion/complete', completeParamsShape), - 'server/discover': dispatchRequest('server/discover', {}) + 'server/discover': dispatchRequest('server/discover', {}), + 'subscriptions/listen': dispatchRequest('subscriptions/listen', subscriptionsListenParamsShape) }; /** Dispatch (post-lift) result schemas, keyed by method — what the funnel @@ -555,7 +567,12 @@ export const dispatchResultSchemas: { readonly [M in Rev2026RequestMethod]: z.Zo capabilities: ServerCapabilities2026Schema, serverInfo: ImplementationSchema, instructions: z.string().optional() - }) + }), + // `subscriptions/listen` never receives a JSON-RPC result: termination is + // stream close (HTTP) or `notifications/cancelled` (stdio). The empty + // entry keeps the mapped type total; the codec's `decodeResult` would + // never be called for this method in practice. + 'subscriptions/listen': liftedResult({}) }; /* ------------------------------------------------------------------------ * @@ -565,9 +582,8 @@ export const dispatchResultSchemas: { readonly [M in Rev2026RequestMethod]: z.Zo * tasks/status, elicitation/complete (removed from the draft together with * URL-elicitation's elicitationId — both remain 2025-11-25 vocabulary only). * The shapes are revision-identical to the shared schemas, which are - * composed by reference, EXCEPT cancelled, which forks below: this revision - * requires `requestId`. (The 2026-only subscriptions/acknowledged - * notification is #14 scope — see registry.ts.) + * composed by reference, EXCEPT cancelled (forks below: this revision + * requires `requestId`) and the 2026-only subscriptions/acknowledged. * ------------------------------------------------------------------------ */ /** @@ -585,6 +601,15 @@ export const NotificationMetaSchema = z.looseObject({ 'io.modelcontextprotocol/subscriptionId': RequestIdSchema.optional() }); +/** Anchor SubscriptionsAcknowledgedNotification (2026-only). */ +export const SubscriptionsAcknowledgedNotificationSchema = z.object({ + method: z.literal('notifications/subscriptions/acknowledged'), + params: z.object({ + _meta: NotificationMetaSchema.optional(), + notifications: SubscriptionFilterSchema + }) +}); + /** * 2026-era `notifications/cancelled` params (anchor-exact fork): `requestId` * is REQUIRED on this revision — the shared schema keeps it optional because @@ -620,7 +645,8 @@ export type Rev2026NotificationMethod = | 'notifications/resources/updated' | 'notifications/resources/list_changed' | 'notifications/tools/list_changed' - | 'notifications/prompts/list_changed'; + | 'notifications/prompts/list_changed' + | 'notifications/subscriptions/acknowledged'; export const notificationSchemas2026: { readonly [M in Rev2026NotificationMethod]: z.ZodType<{ method: M }> } = { 'notifications/cancelled': CancelledNotificationSchema, @@ -629,7 +655,8 @@ export const notificationSchemas2026: { readonly [M in Rev2026NotificationMethod 'notifications/resources/updated': ResourceUpdatedNotificationSchema, 'notifications/resources/list_changed': ResourceListChangedNotificationSchema, 'notifications/tools/list_changed': ToolListChangedNotificationSchema, - 'notifications/prompts/list_changed': PromptListChangedNotificationSchema + 'notifications/prompts/list_changed': PromptListChangedNotificationSchema, + 'notifications/subscriptions/acknowledged': SubscriptionsAcknowledgedNotificationSchema }; /* ------------------------------------------------------------------------ * diff --git a/packages/core/test/corpus/specCorpus.test.ts b/packages/core/test/corpus/specCorpus.test.ts index f5b2c5e820..b546d5135e 100644 --- a/packages/core/test/corpus/specCorpus.test.ts +++ b/packages/core/test/corpus/specCorpus.test.ts @@ -65,8 +65,8 @@ const ERROR_OBJECT_DIRS = new Set([ * fails loudly. These burn down as the corresponding features land. */ const PENDING_2026: Record = { - SubscriptionsAcknowledgedNotification: 'subscriptions/listen vocabulary (SEP-1865) is not modeled yet', - SubscriptionsListenRequest: 'subscriptions/listen vocabulary (SEP-1865) is not modeled yet' + // (empty — the subscriptions/listen vocabulary (SEP-1865) burned when + // the entry-handled listen routers landed.) }; /** diff --git a/packages/core/test/spec.types.2026-07-28.test.ts b/packages/core/test/spec.types.2026-07-28.test.ts index 23167146ae..f88b8e11f3 100644 --- a/packages/core/test/spec.types.2026-07-28.test.ts +++ b/packages/core/test/spec.types.2026-07-28.test.ts @@ -83,6 +83,36 @@ type WCancelledNotification = z4.infer; type WNotificationMeta = z4.infer; +/* Subscriptions vocabulary (SEP-1865) — modeled by the 2026-era wire module. */ +type WSubscriptionFilter = z4.infer; +type WSubscriptionsListenRequest = z4.infer; +type WSubscriptionsListenRequestParams = WSubscriptionsListenRequest['params']; +type WSubscriptionsAcknowledgedNotification = z4.infer; +type WSubscriptionsAcknowledgedNotificationParams = WSubscriptionsAcknowledgedNotification['params']; +// The anchor's ClientRequest union, composed from the era module's wire requests. +type WClientRequest = + | WCompleteRequest + | WListPromptsRequest + | WListResourceTemplatesRequest + | WListResourcesRequest + | WListToolsRequest + | WDiscoverRequest + | WCallToolRequest + | WGetPromptRequest + | WReadResourceRequest + | WSubscriptionsListenRequest; +// The anchor's ServerNotification union (cancelled fork; the four +// subscription-gated change notifications use neutral params shapes). +type WServerNotification = + | WCancelledNotification + | SDKTypes.ProgressNotification + | SDKTypes.LoggingMessageNotification + | SDKTypes.ResourceListChangedNotification + | (Omit & { params: { _meta?: WNotificationMeta; uri: string } }) + | SDKTypes.ToolListChangedNotification + | SDKTypes.PromptListChangedNotification + | WSubscriptionsAcknowledgedNotification; + /* Multi-round-trip vocabulary (SEP-2322) — modeled by the 2026-era wire module. */ type WInputRequest = z4.infer; type WInputRequests = z4.infer; @@ -733,6 +763,40 @@ const wireParityChecks = { ServerResult: (sdk: WServerResult, spec: SpecTypes.ServerResult) => { sdk = spec; spec = sdk; + }, + SubscriptionFilter: (sdk: WSubscriptionFilter, spec: SpecTypes.SubscriptionFilter) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsListenRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.SubscriptionsListenRequest) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsListenRequestParams: (sdk: WSubscriptionsListenRequestParams, spec: SpecTypes.SubscriptionsListenRequestParams) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsAcknowledgedNotification: ( + sdk: WithJSONRPC, + spec: SpecTypes.SubscriptionsAcknowledgedNotification + ) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsAcknowledgedNotificationParams: ( + sdk: WSubscriptionsAcknowledgedNotificationParams, + spec: SpecTypes.SubscriptionsAcknowledgedNotificationParams + ) => { + sdk = spec; + spec = sdk; + }, + ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { + sdk = spec; + spec = sdk; + }, + ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { + sdk = spec; + spec = sdk; } }; @@ -751,18 +815,9 @@ const FEATURE_OWNED_PENDING_2026: Record = { // Inlined in the SDK (same as the 2025-11-25 comparison): Error: 'the inner error object of a JSONRPCError is inlined in the SDK', - // (The M4.1 MRTR partition burned down when the multi-round-trip wire - // vocabulary landed in wire/rev2026-07-28 — see the wireParityChecks - // entries for InputRequest/InputRequiredResult/… above.) - - // M6.1 subscriptions/listen (#14): - SubscriptionFilter: 'M6.1 subscriptions/listen (#14)', - SubscriptionsAcknowledgedNotification: 'M6.1 subscriptions/listen (#14)', - SubscriptionsAcknowledgedNotificationParams: 'M6.1 subscriptions/listen (#14)', - SubscriptionsListenRequest: 'M6.1 subscriptions/listen (#14)', - SubscriptionsListenRequestParams: 'M6.1 subscriptions/listen (#14)', - ClientRequest: 'M6.1 subscriptions/listen (#14) — the union gains SubscriptionsListenRequest', - ServerNotification: 'M6.1 subscriptions/listen (#14) — the union gains the acknowledged notification', + // (The M4.1 MRTR and M6.1 subscriptions/listen partitions burned down + // when their wire vocabulary landed in wire/rev2026-07-28 — see the + // wireParityChecks entries above.) // M1.2 validation ladder (#8): the per-code error response envelopes: HeaderMismatchError: 'M1.2 validation ladder (#8)', diff --git a/packages/core/test/wire/registryDiffOracle.test.ts b/packages/core/test/wire/registryDiffOracle.test.ts index c782e16664..6bdd2937c8 100644 --- a/packages/core/test/wire/registryDiffOracle.test.ts +++ b/packages/core/test/wire/registryDiffOracle.test.ts @@ -14,11 +14,6 @@ * methods in 2026 — the server→client JSON-RPC request channel is deleted * (`ServerRequest` has no 2026 export; the shapes survive only as in-band * `InputRequest` payloads, M4.1/#13). - * - 2026 DEFERRALS: `subscriptions/listen` and - * `notifications/subscriptions/acknowledged` are real 2026 wire methods - * whose SHELLS land with the subscriptions feature (M6.1/#14). The day #14 - * wires them, this oracle fails until the entries are removed — that - * failure is the burn-down notification, by design. */ import fs from 'node:fs'; import path from 'node:path'; @@ -50,9 +45,7 @@ const SEED_EXCLUSIONS: Record> = { '2026-07-28': { 'sampling/createMessage': 'DEMOTED to an in-band InputRequest payload (M4.1/#13) — not a 2026 wire request', 'elicitation/create': 'DEMOTED to an in-band InputRequest payload (M4.1/#13) — not a 2026 wire request', - 'roots/list': 'DEMOTED to an in-band InputRequest payload (M4.1/#13) — not a 2026 wire request', - 'subscriptions/listen': 'DEFERRED to the subscriptions feature (M6.1/#14)', - 'notifications/subscriptions/acknowledged': 'DEFERRED to the subscriptions feature (M6.1/#14)' + 'roots/list': 'DEMOTED to an in-band InputRequest payload (M4.1/#13) — not a 2026 wire request' } }; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f3f1a885d0..fad5736d6a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -43,6 +43,9 @@ export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerR export { PerRequestHTTPServerTransport } from './server/perRequestTransport.js'; export type { ServerOptions } from './server/server.js'; export { Server } from './server/server.js'; +// subscriptions/listen change-event sourcing seam (protocol revision 2026-07-28). +export type { ServerEvent, ServerEventBus, ServerNotifier } from './server/serverEventBus.js'; +export { InMemoryServerEventBus } from './server/serverEventBus.js'; // StdioServerTransport and the serveStdio entry are exported from the './stdio' subpath — server stdio // has only type-level Node imports (erased at compile time), but matching the client's `./stdio` subpath // gives consumers a consistent shape across packages. diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index e5f79dec85..3afd69fbf1 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -34,8 +34,6 @@ import type { InboundLadderRejection, InboundLegacyRoute, InboundModernRoute, - JSONRPCNotification, - JSONRPCRequest, RequestId } from '@modelcontextprotocol/core'; import { @@ -56,10 +54,13 @@ import { } from '@modelcontextprotocol/core'; import { invoke } from './invoke.js'; +import { createListenRouter, DEFAULT_LISTEN_KEEPALIVE_MS, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter.js'; import { McpServer } from './mcp.js'; import type { PerRequestResponseMode } from './perRequestTransport.js'; import type { Server } from './server.js'; import { installModernOnlyHandlers, seedClientIdentityFromEnvelope } from './server.js'; +import type { ServerEventBus, ServerNotifier } from './serverEventBus.js'; +import { createServerNotifier, InMemoryServerEventBus } from './serverEventBus.js'; import { WebStandardStreamableHTTPServerTransport } from './streamableHttp.js'; /* ------------------------------------------------------------------------ * @@ -171,6 +172,28 @@ export interface CreateMcpHandlerOptions { * always served over SSE regardless of this setting. */ responseMode?: PerRequestResponseMode; + /** + * The change-event bus `subscriptions/listen` streams subscribe to. + * + * When omitted, an in-process {@link InMemoryServerEventBus} is created + * and the returned handler's `notify` sugar publishes onto it. + * Multi-process deployments supply their own implementation over their + * pub/sub backend; the same instance can be shared across handlers. + */ + bus?: ServerEventBus; + /** + * Reject a new `subscriptions/listen` with `-32603` 'Subscription limit + * reached' (in-band, HTTP 200, before the ack) when this many subscription + * streams are already open on this handler. + * @default 1024 + */ + maxSubscriptions?: number; + /** + * SSE comment-frame keepalive interval for `subscriptions/listen` streams, + * in milliseconds. Set to `0` to disable. + * @default 15000 + */ + keepAliveMs?: number; } /** @@ -216,6 +239,20 @@ export interface McpHttpHandler { * between exchanges. */ close: () => Promise; + /** + * Typed publish-side facade over the handler's `subscriptions/listen` bus: + * each method publishes the corresponding change event to every open + * subscription stream that opted in to that notification type. + * + * Safe to call when no subscription is open (no-op). + */ + notify: ServerNotifier; + /** + * The change-event bus this handler's `subscriptions/listen` streams + * subscribe to (the supplied `bus` option, or the auto-created in-process + * default). + */ + bus: ServerEventBus; } /* ------------------------------------------------------------------------ * @@ -260,16 +297,6 @@ function toError(value: unknown): Error { return value instanceof Error ? value : new Error(String(value)); } -/** - * Whether the given factory product has the (forthcoming) subscriptions feature - * configured. The subscriptions registry does not exist yet, so this currently - * always reports `false`; the subscriptions feature replaces this predicate - * when it lands, which arms the `responseMode: 'json'` startup warning below. - */ -function hasConfiguredSubscriptions(_product: McpServer | Server): boolean { - return false; -} - function internalServerErrorResponse(id: RequestId | null = null): Response { return jsonRpcErrorResponse(500, -32_603, 'Internal server error', undefined, id); } @@ -572,7 +599,6 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa /** Modern per-request instances with an exchange still in flight (close() tears these down). */ const inflight = new Set(); let closed = false; - let warnedJsonModeSubscriptions = false; const reportError = (error: Error) => { try { @@ -582,16 +608,27 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } }; + const bus: ServerEventBus = options.bus ?? new InMemoryServerEventBus(reportError); + const notify = createServerNotifier(bus); + const listenRouter = createListenRouter({ + bus, + maxSubscriptions: options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS, + keepAliveMs: options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS, + onerror: reportError + }); + if (responseMode === 'json') { + // eslint-disable-next-line no-console + console.warn( + "responseMode: 'json' drops mid-call notifications. subscriptions/listen streams are always served over SSE regardless; " + + 'other notifications emitted before a result are dropped.' + ); + } + // The default posture is the stateless fallback; 'reject' is the only way // to turn legacy serving off (modern-only strict). const legacyHandler: LegacyHttpHandler | undefined = legacy === 'reject' ? undefined : legacyStatelessFallback(factory, reportError); - async function serveModern( - route: InboundModernRoute, - message: JSONRPCRequest | JSONRPCNotification, - request: Request, - authInfo: AuthInfo | undefined - ): Promise { + async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise { const claimedRevision = route.classification.revision; if (claimedRevision === undefined || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedRevision)) { // The claim names a revision this endpoint does not serve (an @@ -602,10 +639,10 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa requested: claimedRevision ?? 'unknown' }); reportError(error); - return jsonRpcErrorResponse(400, error.code, error.message, error.data, echoableRequestId(message)); + return jsonRpcErrorResponse(400, error.code, error.message, error.data, echoableRequestId(route.message)); } - const meta = route.messageKind === 'request' ? requestMetaOf((message as JSONRPCRequest).params) : undefined; + const meta = route.messageKind === 'request' ? requestMetaOf(route.message.params) : undefined; const declaredClientCapabilities = meta?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; // Pre-dispatch capability gate: a request to a method whose processing @@ -615,7 +652,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // spec-mandated HTTP 400 for this error; a handler-time emission would // surface in-band on HTTP 200. if (route.messageKind === 'request') { - const required = requiredClientCapabilitiesForRequest((message as JSONRPCRequest).method); + const required = requiredClientCapabilitiesForRequest(route.message.method); if (required !== undefined) { const missing = missingClientCapabilities(required, declaredClientCapabilities); if (missing !== undefined) { @@ -626,7 +663,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa error.code, error.message, error.data, - (message as JSONRPCRequest).id + route.message.id ); } } @@ -639,6 +676,23 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa }); const server = product instanceof McpServer ? product.server : product; + // Entry-handled `subscriptions/listen`: the router owns ack-first / + // per-stream filtering / subscription-id stamping / keepalive / + // capacity / teardown. The factory IS constructed for listen — to read + // the instance's declared capabilities only, so the acknowledged + // filter reflects what the server can actually deliver. Unlike the + // discover path (which connects via the per-request transport and tears + // down with it), the probe instance is never connected: capabilities + // are read off the unconnected instance and it is closed immediately. + // Authorization the consumer performs inside the factory therefore DOES + // see listen requests, although token verification still belongs at the + // middleware layer mounted in front of this entry. + if (route.messageKind === 'request' && route.message.method === 'subscriptions/listen') { + const capabilities = server.getCapabilities(); + void product.close().catch(reportError); + return listenRouter.serve(route.message, request.signal, capabilities); + } + // Era-write at instance binding, then modern-only handler installation — // both before the instance is connected to the per-request transport. setNegotiatedProtocolVersion(server, claimedRevision); @@ -651,15 +705,6 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa }); } - if (responseMode === 'json' && !warnedJsonModeSubscriptions && hasConfiguredSubscriptions(product)) { - warnedJsonModeSubscriptions = true; - // eslint-disable-next-line no-console - console.warn( - "Warning: responseMode: 'json' drops mid-call notifications, but this server configures subscriptions. " + - 'Subscription (listen) streams are always served over SSE; other notifications emitted before a result will be dropped.' - ); - } - // Track the instance until its exchange tears down so close() can abort it. const previousOnClose = server.onclose; inflight.add(server); @@ -668,19 +713,12 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa previousOnClose?.(); }; - // Listen-class streams are always SSE: even under 'json', a listen - // request's per-request transport keeps the lazy upgrade available. - const effectiveResponseMode: PerRequestResponseMode | undefined = - responseMode === 'json' && route.messageKind === 'request' && (message as JSONRPCRequest).method === 'subscriptions/listen' - ? 'auto' - : responseMode; - try { - const response = await invoke(product, message, { + const response = await invoke(product, route.message, { classification: route.classification, request, ...(authInfo !== undefined && { authInfo }), - ...(effectiveResponseMode !== undefined && { responseMode: effectiveResponseMode }) + ...(responseMode !== undefined && { responseMode }) }); if (route.messageKind === 'notification') { // Notification exchanges have no terminal response to ride the @@ -701,7 +739,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa await server.close().catch(() => {}); inflight.delete(server); reportError(toError(error)); - return internalServerErrorResponse(echoableRequestId(message)); + return internalServerErrorResponse(echoableRequestId(route.message)); } } @@ -753,7 +791,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return rejectionResponse(outcome, echoableRequestId(body)); } case 'modern': { - return await serveModern(outcome, body as JSONRPCRequest | JSONRPCNotification, request, authInfo); + return await serveModern(outcome, request, authInfo); } case 'legacy': { return await serveLegacyRoute(outcome, forwardRequest, authInfo, parsedBody); @@ -854,8 +892,11 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return { fetch: fetchFace, node: nodeFace, + notify, + bus, close: async () => { closed = true; + listenRouter.closeAll(); const closing = [...inflight].map(server => server.close().catch(() => {})); inflight.clear(); await Promise.all(closing); diff --git a/packages/server/src/server/listenRouter.ts b/packages/server/src/server/listenRouter.ts new file mode 100644 index 0000000000..4f6c5ccaa1 --- /dev/null +++ b/packages/server/src/server/listenRouter.ts @@ -0,0 +1,382 @@ +/** + * The entry-handled `subscriptions/listen` router for the HTTP serving entry. + * + * `createMcpHandler` recognizes a modern-classified `subscriptions/listen` + * request and routes it here: the entry owns ack-first, per-stream filtering, + * subscription-id stamping, keepalive, capacity guarding, and teardown. The + * consumer's factory IS constructed for listen, to read the instance's + * declared `ServerCapabilities` only — the probe instance is never connected + * and is closed immediately after the capabilities read. Token verification + * and any per-request authorization still belong at the middleware layer + * mounted in front of `createMcpHandler` (the entry's documented authz + * posture). + * + * Per the spec at protocol revision 2026-07-28: + * - The acknowledged notification is the FIRST message on the stream and + * carries the honored subset of the requested filter. + * - Every notification on the stream (including the ack) carries the listen + * request's JSON-RPC id under `_meta['io.modelcontextprotocol/subscriptionId']`. + * - The server MUST NOT deliver a notification type the client did not request. + * - Termination is stream close (HTTP); no JSON-RPC result is ever emitted. + */ +import type { JSONRPCRequest, RequestId, ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core'; +import { SUBSCRIPTION_ID_META_KEY, SubscriptionFilterSchema } from '@modelcontextprotocol/core'; + +import type { ServerEventBus } from './serverEventBus.js'; +import { honoredSubset, listenFilterAccepts, serverEventToNotification } from './serverEventBus.js'; + +/** Default SSE comment-frame keepalive interval for listen streams. */ +export const DEFAULT_LISTEN_KEEPALIVE_MS = 15_000; + +/** Default capacity guard: refuse a new subscription when this many are already open. */ +export const DEFAULT_MAX_SUBSCRIPTIONS = 1024; + +/** Options for {@linkcode createListenRouter}. */ +export interface ListenRouterOptions { + /** The event bus listen streams subscribe to. */ + bus: ServerEventBus; + /** Reject a new listen with `-32603` when this many subscriptions are already open (default 1024). */ + maxSubscriptions?: number; + /** SSE comment-frame keepalive interval; `0` disables keepalive (default 15000). */ + keepAliveMs?: number; + /** Out-of-band error reporting (never alters the response). */ + onerror?: (error: Error) => void; +} + +/** + * A wire-shape notification body (method + loose params). + * @internal + */ +export interface NotificationBody { + method: string; + params: { _meta?: Record; [key: string]: unknown }; +} + +function jsonRpcError(id: RequestId | null, code: number, message: string): Response { + return Response.json({ jsonrpc: '2.0', error: { code, message }, id }, { status: 200 }); +} + +/** Stamp the subscription id onto a notification's `_meta`. Non-mutating. */ +function stampSubscriptionId( + notification: { method: string; params?: { _meta?: Record; [key: string]: unknown } }, + subscriptionId: RequestId +): NotificationBody { + return { + method: notification.method, + params: { + ...notification.params, + _meta: { ...notification.params?._meta, [SUBSCRIPTION_ID_META_KEY]: subscriptionId } + } + }; +} + +/** + * Read the requested filter off a `subscriptions/listen` request body. + * Returns the validated filter, or `undefined` when `params.notifications` + * is absent or fails the schema (the caller answers `-32602` — the spec + * marks `notifications` REQUIRED on the listen request). + */ +export function parseListenFilter(message: JSONRPCRequest): SubscriptionFilter | undefined { + const raw: unknown = message.params?.['notifications']; + if (raw === undefined) return undefined; + const parsed = SubscriptionFilterSchema.safeParse(raw); + return parsed.success ? parsed.data : undefined; +} + +/** + * The HTTP listen router: holds the set of open subscriptions and serves + * each listen request as an SSE response. + */ +export interface ListenRouter { + /** + * Serve one `subscriptions/listen` request and return the SSE `Response` + * (or, on capacity / params rejection, the in-band JSON-RPC error + * `Response`). The ack notification is the first SSE frame. + * + * `capabilities` is required: the acknowledged filter is always narrowed + * against what the serving instance advertises (honoring a filter without + * capabilities would fail open and deliver unadvertised types). + */ + serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities): Response; + /** + * Close every open subscription stream (HTTP teardown is stream close — + * no JSON-RPC result is written). + */ + closeAll(): void; + /** The number of currently open subscription streams (for tests / introspection). */ + readonly openCount: number; +} + +export function createListenRouter(options: ListenRouterOptions): ListenRouter { + const { bus, onerror } = options; + const maxSubscriptions = options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS; + const keepAliveMs = options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS; + + const open = new Set<() => void>(); + + function serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities): Response { + // Capacity guard, pre-ack: in-band -32603 on HTTP 200. + if (open.size >= maxSubscriptions) { + onerror?.(new Error(`subscriptions/listen refused: subscription limit reached (${maxSubscriptions})`)); + return jsonRpcError(message.id, -32_603, 'Subscription limit reached'); + } + const filter = parseListenFilter(message); + if (filter === undefined) { + return jsonRpcError(message.id, -32_602, "Invalid params: 'notifications' is required and must be a valid SubscriptionFilter"); + } + const honored = honoredSubset(filter, capabilities); + // The spec carries the listen request's JSON-RPC id verbatim as the + // subscription id; demux is per-connection (each HTTP listen has its + // own SSE stream) so client-chosen ids cannot route across requests. + const subscriptionId = message.id; + + const encoder = new TextEncoder(); + let controller!: ReadableStreamDefaultController; + let closed = false; + let unsubscribe: (() => void) | undefined; + let keepAliveTimer: ReturnType | undefined; + let abortCleanup: (() => void) | undefined; + + const writeFrame = (frame: string) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(frame)); + } catch (error) { + onerror?.(error instanceof Error ? error : new Error(String(error))); + } + }; + const writeNotification = (method: string, params: { _meta?: Record; [key: string]: unknown }) => { + writeFrame(`event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', method, params })}\n\n`); + }; + + const teardown = () => { + if (closed) return; + closed = true; + unsubscribe?.(); + if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer); + abortCleanup?.(); + open.delete(teardown); + try { + controller.close(); + } catch { + // Already closed/cancelled by the consumer. + } + }; + + const readable = new ReadableStream({ + start(streamController) { + controller = streamController; + + // Ack-first MUST: the acknowledged notification is the first + // frame on the stream, stamped with the subscription id. + const ack = stampSubscriptionId( + { method: 'notifications/subscriptions/acknowledged', params: { notifications: honored } }, + subscriptionId + ); + writeNotification(ack.method, ack.params); + + // Only after the ack frame is enqueued does delivery activate. + unsubscribe = bus.subscribe(event => { + if (closed || !listenFilterAccepts(honored, event)) return; + const note = stampSubscriptionId(serverEventToNotification(event), subscriptionId); + writeNotification(note.method, note.params); + }); + + if (keepAliveMs > 0) { + keepAliveTimer = setInterval(() => writeFrame(': keepalive\n\n'), keepAliveMs); + // Do not hold the event loop open on idle subscriptions. Node's + // setInterval returns a Timeout with .unref(); browsers/Workers + // return a number — the cast is an environment shim, not a + // workaround for SDK typing. + (keepAliveTimer as { unref?: () => void }).unref?.(); + } + + open.add(teardown); + }, + cancel() { + // The client closed the SSE stream — the spec's HTTP cancel signal. + teardown(); + } + }); + + if (signal !== undefined) { + if (signal.aborted) { + teardown(); + } else { + const onAbort = () => teardown(); + signal.addEventListener('abort', onAbort, { once: true }); + abortCleanup = () => signal.removeEventListener('abort', onAbort); + } + } + + return new Response(readable, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); + } + + return { + serve, + closeAll() { + for (const teardown of open) teardown(); + }, + get openCount() { + return open.size; + } + }; +} + +/* ------------------------------------------------------------------------ * + * Stdio listen router + * ------------------------------------------------------------------------ */ + +const CHANGE_NOTIFICATION_METHODS: ReadonlySet = new Set([ + 'notifications/tools/list_changed', + 'notifications/prompts/list_changed', + 'notifications/resources/list_changed', + 'notifications/resources/updated' +]); + +/** + * Per-connection listen state for the stdio entry. One instance is held by + * `serveStdio` for the connection lifetime; it routes inbound + * `subscriptions/listen` / `notifications/cancelled` and rewrites outbound + * change notifications onto the active subscriptions. No bus — the long-lived + * pinned instance's existing `send*ListChanged()` calls feed straight into + * `routeOutbound()`. + */ +export class StdioListenRouter { + /** Active subscriptions, keyed by the listen request's JSON-RPC id verbatim. */ + private readonly _subs = new Map(); + /** + * The serving instance's declared capabilities. Filled in by the entry + * once the modern instance is constructed (the router is created before + * the instance exists), so the acknowledged filter is narrowed against + * what the server can actually deliver. + */ + private _serverCapabilities: ServerCapabilities | undefined; + + constructor( + private readonly _maxSubscriptions: number = DEFAULT_MAX_SUBSCRIPTIONS, + serverCapabilities?: ServerCapabilities + ) { + this._serverCapabilities = serverCapabilities; + } + + /** + * Record the serving instance's declared capabilities once it has been + * constructed. Called by `serveStdio`'s connect path; subsequent + * `serve()` calls narrow the honored filter against these. + */ + setServerCapabilities(capabilities: ServerCapabilities): void { + this._serverCapabilities = capabilities; + } + + /** Whether `id` is an active listen subscription on this connection. */ + has(id: RequestId): boolean { + return this._subs.has(id); + } + + /** + * Serve one inbound `subscriptions/listen` request: registers the + * subscription and returns the stamped acknowledged notification (or, on + * capacity / params rejection, the in-band JSON-RPC error response). + * + * @throws when called before {@linkcode setServerCapabilities} (or the + * constructor) has supplied the serving instance's capabilities. Honoring a + * filter without knowing the server's advertised capabilities would fail + * open (deliver unadvertised types); the entry guarantees capabilities are + * set before any listen request is routed here. + */ + serve(message: JSONRPCRequest): NotificationBody | { jsonrpc: '2.0'; id: RequestId; error: { code: number; message: string } } { + if (this._serverCapabilities === undefined) { + throw new Error( + 'StdioListenRouter.serve() called before setServerCapabilities(); refusing to honor a filter without capabilities' + ); + } + if (this._subs.size >= this._maxSubscriptions) { + return { jsonrpc: '2.0', id: message.id, error: { code: -32_603, message: 'Subscription limit reached' } }; + } + const filter = parseListenFilter(message); + if (filter === undefined) { + return { + jsonrpc: '2.0', + id: message.id, + error: { code: -32_602, message: "Invalid params: 'notifications' is required and must be a valid SubscriptionFilter" } + }; + } + const honored = honoredSubset(filter, this._serverCapabilities); + this._subs.set(message.id, honored); + return stampSubscriptionId({ method: 'notifications/subscriptions/acknowledged', params: { notifications: honored } }, message.id); + } + + /** + * Tear down one subscription (inbound `notifications/cancelled`). Returns + * `true` when a subscription was removed. After this call NOTHING further + * is delivered for that subscription id (the post-cancel hardening). + */ + cancel(id: RequestId): boolean { + return this._subs.delete(id); + } + + /** + * Route an outbound notification through the active subscriptions. + * + * - For a subscription-gated change notification, returns one stamped copy + * per subscription that opted in to it (an empty array means it is + * dropped — the modern era never delivers an un-requested change type). + * - For any other outbound message, returns `'passthrough'` (the entry + * forwards it as-is). + */ + routeOutbound(message: { method: string; params?: { [key: string]: unknown } }): NotificationBody[] | 'passthrough' { + if (!CHANGE_NOTIFICATION_METHODS.has(message.method)) { + return 'passthrough'; + } + const uriParam: unknown = message.params?.['uri']; + const uri = typeof uriParam === 'string' ? uriParam : undefined; + const event = notificationToServerEvent(message.method, uri); + const out: NotificationBody[] = []; + for (const [subscriptionId, filter] of this._subs) { + if (listenFilterAccepts(filter, event)) { + out.push(stampSubscriptionId({ method: message.method, params: message.params ?? {} }, subscriptionId)); + } + } + return out; + } + + /** + * Server-side teardown of every active subscription: returns the single + * `notifications/cancelled` per subscription id the entry MUST emit on + * stdio teardown (and clears the set so nothing further is delivered). + */ + teardownAll(): { jsonrpc: '2.0'; method: 'notifications/cancelled'; params: { requestId: RequestId } }[] { + const out: { jsonrpc: '2.0'; method: 'notifications/cancelled'; params: { requestId: RequestId } }[] = []; + for (const id of this._subs.keys()) { + out.push({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id } }); + } + this._subs.clear(); + return out; + } +} + +function notificationToServerEvent(method: string, uri: string | undefined): import('./serverEventBus.js').ServerEvent { + switch (method) { + case 'notifications/tools/list_changed': { + return { kind: 'tools_list_changed' }; + } + case 'notifications/prompts/list_changed': { + return { kind: 'prompts_list_changed' }; + } + case 'notifications/resources/list_changed': { + return { kind: 'resources_list_changed' }; + } + default: { + return { kind: 'resource_updated', uri: uri ?? '' }; + } + } +} diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index 7511a3ece6..496e3d6ac8 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -75,6 +75,7 @@ import { } from '@modelcontextprotocol/core'; import type { McpServerFactory } from './createMcpHandler.js'; +import { DEFAULT_MAX_SUBSCRIPTIONS, StdioListenRouter } from './listenRouter.js'; import { McpServer } from './mcp.js'; import type { Server } from './server.js'; import { installModernOnlyHandlers } from './server.js'; @@ -106,6 +107,13 @@ export interface ServeStdioOptions { transport?: Transport; /** Callback for out-of-band errors (reporting only; it never alters what is written to the wire). */ onerror?: (error: Error) => void; + /** + * Reject a new `subscriptions/listen` with `-32603` 'Subscription limit + * reached' (in-band, before the ack) when this many subscriptions are + * already open on this connection. + * @default 1024 + */ + maxSubscriptions?: number; } /** The handle returned by {@linkcode serveStdio}. */ @@ -147,7 +155,15 @@ class StdioConnectionChannel implements Transport { constructor( private readonly _wire: Transport, - private readonly _onInstanceClose: () => void + private readonly _onInstanceClose: () => void, + /** + * Optional first-look on outbound messages. When set and returning + * `'handled'`, the channel does not write the message to the wire + * (the entry already wrote whatever was appropriate). Used by the + * modern-era listen router to fan a change notification out onto the + * active subscriptions instead of broadcasting it unsolicited. + */ + private readonly _outboundIntercept?: (message: JSONRPCMessage) => 'handled' | undefined ) {} async start(): Promise { @@ -170,6 +186,9 @@ class StdioConnectionChannel implements Transport { // sends are dropped. return; } + if (this._outboundIntercept?.(message) === 'handled') { + return; + } return this._wire.send(message, options); } @@ -383,6 +402,86 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions .send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined && { data }) } }) .catch(error => reportError(toError(error))); + /** + * Entry-handled `subscriptions/listen` for this connection: holds the + * active subscriptions, serves inbound listen / cancelled-of-listen + * before the pinned instance is consulted, and rewrites the instance's + * outbound change notifications onto the active subscriptions. Only + * consulted on a modern-pinned connection — on a legacy connection + * change notifications pass straight through (the 2025 unsolicited + * delivery model is unchanged). + */ + const listenRouter = new StdioListenRouter(options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS); + + /** Outbound intercept installed on a modern instance's channel. */ + const modernOutboundIntercept = (message: JSONRPCMessage): 'handled' | undefined => { + if (!isJSONRPCNotification(message)) return undefined; + const routed = listenRouter.routeOutbound(message); + if (routed === 'passthrough') return undefined; + // A subscription-gated change notification on the modern era: one + // stamped copy per subscription that opted in (an empty array means + // it is dropped — the modern era never delivers an un-requested + // change type unsolicited). Nothing else from the instance is + // affected. + for (const stamped of routed) { + void wire.send({ jsonrpc: '2.0', ...stamped }).catch(error => reportError(toError(error))); + } + return 'handled'; + }; + + /** + * Entry-handled inbound listen routing for a modern-pinned connection. + * Returns `true` when the message was served at the entry and must NOT + * be delivered to the pinned instance. + */ + const tryServeListen = async (message: JSONRPCMessage): Promise => { + if (isJSONRPCRequest(message) && message.method === 'subscriptions/listen') { + // Entry-handled listen is its own request-handling subsystem; it + // applies the same per-request envelope rung the instance's + // `_onrequest` would (method-existence is N/A here — the entry + // recognized the method — so envelope validation is the first + // applicable rung) and the same supported-revision check the + // opening classifier and the HTTP entry apply per request. Reuses + // the same validators the opening classifier uses. + const meta = requestMetaOf(message.params); + const issue = hasEnvelopeClaim(message.params) + ? (meta === undefined ? [] : validateEnvelopeMeta(meta))[0] + : { key: '_meta', problem: 'the per-request envelope is required on protocol revision 2026-07-28' }; + const claimedVersion = envelopeClaimVersion(message.params); + let reply; + if (issue !== undefined) { + reply = { + jsonrpc: '2.0' as const, + id: message.id, + error: { code: -32_602, message: `Invalid _meta envelope: ${issue.key}: ${issue.problem}` } + }; + } else if (claimedVersion === undefined || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedVersion)) { + const error = new UnsupportedProtocolVersionError({ + supported: [...SUPPORTED_MODERN_PROTOCOL_VERSIONS], + requested: claimedVersion ?? 'unknown' + }); + reply = { jsonrpc: '2.0' as const, id: message.id, error: { code: error.code, message: error.message, data: error.data } }; + } else { + reply = listenRouter.serve(message); + } + await wire + .send('error' in reply ? reply : { jsonrpc: '2.0', method: reply.method, params: reply.params }) + .catch(error => reportError(toError(error))); + return true; + } + if (isJSONRPCNotification(message) && message.method === 'notifications/cancelled') { + const cancelledId = (message.params as CancelledNotificationParams | undefined)?.requestId; + // Inbound cancel of a parked listen: tear the subscription down + // and DO NOT deliver to the instance (it never saw the listen + // request). After this point nothing further is delivered for + // that subscription id (post-cancel hardening). + if (cancelledId !== undefined && listenRouter.cancel(cancelledId)) { + return true; + } + } + return false; + }; + /** Answers a 2025-era request the entry will not serve (the modern-only rejection cells). */ const answerLegacyRejection = ( request: JSONRPCRequest, @@ -418,8 +517,16 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions // uses, before the instance is connected. setNegotiatedProtocolVersion(server, revision); installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS); + // The listen router was created before this instance existed; now + // that capabilities are known, hand them over so the acknowledged + // filter is narrowed against what the server actually advertises. + listenRouter.setServerCapabilities(server.getCapabilities()); } - const channel: StdioConnectionChannel = new StdioConnectionChannel(wire, () => onInstanceClosed(channel)); + const channel: StdioConnectionChannel = new StdioConnectionChannel( + wire, + () => onInstanceClosed(channel), + era === 'modern' ? modernOutboundIntercept : undefined + ); await product.connect(channel); return { product, channel }; }; @@ -487,6 +594,9 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions await answerLegacyRejection(message, 'initialize', requestedVersion); return; } + if (state.era === 'modern' && (await tryServeListen(message))) { + return; + } state.instance.channel.deliver(message); return; } @@ -578,6 +688,9 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions } state = { phase: 'pinned', era: 'modern', instance }; } + if (await tryServeListen(message)) { + return; + } state.instance.channel.deliver(message, { classification: opening.classification }); return; } @@ -661,6 +774,12 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions closing = true; const current = state; state = { phase: 'closed' }; + // Stdio server-side teardown: emit ONE `notifications/cancelled` per + // active listen subscription referencing the listen request id (the + // spec MUST), before the wire is closed. + for (const cancelled of listenRouter.teardownAll()) { + await wire.send(cancelled).catch(error => reportError(toError(error))); + } if (current.phase === 'probe' || current.phase === 'pinned') { await current.instance.product.close().catch(error => reportError(toError(error))); } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 905d6cae97..4b056cf123 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1097,26 +1097,14 @@ export class Server extends Protocol { } /** - * The capability set a server advertises on `server/discover`: until the - * `subscriptions/listen` flow ships, the advertisement excludes the - * listChanged/subscribe-class capabilities, which a modern-era connection - * cannot be served yet. Pure — never mutates the input; the legacy - * `initialize` advertisement is untouched. + * The capability set a server advertises on `server/discover`. Pure — never + * mutates the input; the legacy `initialize` advertisement is untouched. + * + * The serving entries serve `subscriptions/listen` themselves, so the + * `listChanged` and `resources.subscribe` capability bits are advertised + * as-is: a modern-era client uses them to decide which notification types to + * request on its listen filter. */ export function discoverAdvertisedCapabilities(capabilities: ServerCapabilities): ServerCapabilities { - const advertised: ServerCapabilities = { ...capabilities }; - if (capabilities.tools) { - advertised.tools = { ...capabilities.tools }; - delete advertised.tools.listChanged; - } - if (capabilities.prompts) { - advertised.prompts = { ...capabilities.prompts }; - delete advertised.prompts.listChanged; - } - if (capabilities.resources) { - advertised.resources = { ...capabilities.resources }; - delete advertised.resources.listChanged; - delete advertised.resources.subscribe; - } - return advertised; + return { ...capabilities }; } diff --git a/packages/server/src/server/serverEventBus.ts b/packages/server/src/server/serverEventBus.ts new file mode 100644 index 0000000000..20575a7455 --- /dev/null +++ b/packages/server/src/server/serverEventBus.ts @@ -0,0 +1,194 @@ +import type { ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core'; + +/** + * A change event a server publishes for delivery on open `subscriptions/listen` + * streams. Each variant maps onto exactly one notification method: + * + * - `tools_list_changed` → `notifications/tools/list_changed` + * - `prompts_list_changed` → `notifications/prompts/list_changed` + * - `resources_list_changed` → `notifications/resources/list_changed` + * - `resource_updated` → `notifications/resources/updated` (carries the URI) + * + * The bus carries the EVENT, not the wire shape — the entry's listen router + * owns subscription-id stamping and per-stream filtering. + */ +export type ServerEvent = + | { kind: 'tools_list_changed' } + | { kind: 'prompts_list_changed' } + | { kind: 'resources_list_changed' } + | { kind: 'resource_updated'; uri: string }; + +/** + * The server-side change-event seam for `subscriptions/listen`. + * + * The serving entry (`createMcpHandler`) owns the per-stream listen router: + * each open `subscriptions/listen` stream registers a listener via + * `subscribe()`, and consumer code (typically via `handler.notify.*` sugar) + * publishes change events via `publish()`. In-process servers can use the + * default {@linkcode InMemoryServerEventBus}; multi-process deployments + * implement this interface over their own pub/sub. + * + * The SDK owns wire semantics (ack-first, filtering, subscription-id + * stamping, teardown); a `ServerEventBus` only sources the events. It MUST + * NOT echo back to the listener that published an event when called from + * inside that listener (no surprise here — the default delivers + * synchronously and listeners never publish). + */ +export interface ServerEventBus { + /** + * Publish a change event to every registered listener. + */ + publish(event: ServerEvent): void; + /** + * Register a listener; returns an idempotent unsubscribe function. + */ + subscribe(listener: (event: ServerEvent) => void): () => void; +} + +/** + * A `ServerEventBus` backed by an in-process listener set. + * + * `publish()` delivers synchronously to the live listener set (a listener + * unsubscribing itself mid-dispatch is safe; the entry's listen-router + * listeners never unsubscribe peers). A throwing listener does not stop + * delivery to the others. + */ +export class InMemoryServerEventBus implements ServerEventBus { + private readonly _listeners = new Set<(event: ServerEvent) => void>(); + + /** + * @param onerror - Optional callback for errors thrown by listeners + * during dispatch. + */ + constructor(private readonly onerror?: (error: Error) => void) {} + + publish(event: ServerEvent): void { + for (const listener of this._listeners) { + try { + listener(event); + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + } + } + + subscribe(listener: (event: ServerEvent) => void): () => void { + this._listeners.add(listener); + let live = true; + return () => { + if (!live) return; + live = false; + this._listeners.delete(listener); + }; + } + + /** The number of currently registered listeners (test/introspection only — the routers track capacity via their own open-subscription set). */ + get listenerCount(): number { + return this._listeners.size; + } +} + +/** + * Typed publish-side facade over `bus.publish` returned by `createMcpHandler`: + * each method publishes the corresponding {@linkcode ServerEvent}. Prefer this + * over calling `bus.publish` directly — the names match the wire methods. + */ +export interface ServerNotifier { + /** Publish `notifications/tools/list_changed` to every open subscription that opted in. */ + toolsChanged(): void; + /** Publish `notifications/prompts/list_changed` to every open subscription that opted in. */ + promptsChanged(): void; + /** Publish `notifications/resources/list_changed` to every open subscription that opted in. */ + resourcesChanged(): void; + /** Publish `notifications/resources/updated` for `uri` to every open subscription that opted in to that URI. */ + resourceUpdated(uri: string): void; +} + +/** Build a {@linkcode ServerNotifier} over a bus. */ +export function createServerNotifier(bus: ServerEventBus): ServerNotifier { + return { + toolsChanged: () => bus.publish({ kind: 'tools_list_changed' }), + promptsChanged: () => bus.publish({ kind: 'prompts_list_changed' }), + resourcesChanged: () => bus.publish({ kind: 'resources_list_changed' }), + resourceUpdated: (uri: string) => bus.publish({ kind: 'resource_updated', uri }) + }; +} + +/** + * Whether a `subscriptions/listen` filter accepts a given change event. + * + * Pure: no I/O, no mutation. The filter governs ONLY the four + * subscription-gated change types — non-gated notifications never reach the + * bus and are not modeled here. + * + * `resource_updated` matches only when `resourceSubscriptions` is present and + * contains the event's URI exactly (per the spec: "for these resource URIs"). + */ +export function listenFilterAccepts(filter: SubscriptionFilter, event: ServerEvent): boolean { + switch (event.kind) { + case 'tools_list_changed': { + return filter.toolsListChanged === true; + } + case 'prompts_list_changed': { + return filter.promptsListChanged === true; + } + case 'resources_list_changed': { + return filter.resourcesListChanged === true; + } + case 'resource_updated': { + return filter.resourceSubscriptions !== undefined && filter.resourceSubscriptions.includes(event.uri); + } + } +} + +/** + * The honored subset of a requested filter: keeps only the fields the client + * explicitly opted in to (drops `false` and absent fields), narrowed against + * the server's declared capabilities when supplied. The serving entry sends + * this back in `notifications/subscriptions/acknowledged` so the ack reflects + * what the server can actually deliver. + * + * - `toolsListChanged` is honored only when `capabilities.tools.listChanged` + * is advertised; likewise `promptsListChanged` / `resourcesListChanged`. + * - `resourceSubscriptions` is honored only when + * `capabilities.resources.subscribe` is advertised. + * + * `capabilities` is optional on this pure helper for test convenience only — + * both wired routers REQUIRE capabilities at the call site (the HTTP router's + * `serve()` takes a required parameter; `StdioListenRouter.serve()` throws + * before `setServerCapabilities()` was called), so the fail-open + * `undefined → honor everything` branch is never reachable on a wired entry. + */ +export function honoredSubset(requested: SubscriptionFilter, capabilities?: ServerCapabilities): SubscriptionFilter { + const honored: SubscriptionFilter = {}; + const allow = (bit: unknown): boolean => capabilities === undefined || bit === true; + if (requested.toolsListChanged === true && allow(capabilities?.tools?.listChanged)) honored.toolsListChanged = true; + if (requested.promptsListChanged === true && allow(capabilities?.prompts?.listChanged)) honored.promptsListChanged = true; + if (requested.resourcesListChanged === true && allow(capabilities?.resources?.listChanged)) honored.resourcesListChanged = true; + if ( + requested.resourceSubscriptions !== undefined && + requested.resourceSubscriptions.length > 0 && + allow(capabilities?.resources?.subscribe) + ) { + honored.resourceSubscriptions = [...requested.resourceSubscriptions]; + } + return honored; +} + +/** Map a {@linkcode ServerEvent} onto its wire notification `{method, params}`. */ +export function serverEventToNotification(event: ServerEvent): { method: string; params?: { uri: string } } { + switch (event.kind) { + case 'tools_list_changed': { + return { method: 'notifications/tools/list_changed' }; + } + case 'prompts_list_changed': { + return { method: 'notifications/prompts/list_changed' }; + } + case 'resources_list_changed': { + return { method: 'notifications/resources/list_changed' }; + } + case 'resource_updated': { + return { method: 'notifications/resources/updated', params: { uri: event.uri } }; + } + } +} diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts new file mode 100644 index 0000000000..479e17b905 --- /dev/null +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -0,0 +1,221 @@ +/** + * createMcpHandler — entry-handled `subscriptions/listen` router. + * + * Covers ack-first (the acknowledged notification is the first frame), + * subscription-id stamping (the listen request's JSON-RPC id verbatim), + * per-stream filtering (un-requested types provably never delivered), + * notify sugar, capacity guard, capability-narrowed honored filter, and + * teardown. + */ +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + SUBSCRIPTION_ID_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'listen-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function listenRequest(id: string | number, filter: Record): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'subscriptions/listen', + params: { _meta: ENVELOPE, notifications: filter } + }) + }); +} + +/** Read N SSE `event: message` payloads from a streaming response, then cancel. */ +async function readMessages(response: Response, n: number): Promise { + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const messages: unknown[] = []; + while (messages.length < n) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const dataLine = frame.split('\n').find(l => l.startsWith('data: ')); + if (dataLine) messages.push(JSON.parse(dataLine.slice(6))); + } + } + await reader.cancel(); + return messages; +} + +function trivialFactory(): () => McpServer { + // Declare every listChanged / subscribe bit so the tests below see the + // requested filter honored as-is (the entry now narrows the ack against + // the per-serve instance's declared capabilities). + return () => + new McpServer( + { name: 'listen-test-server', version: '1.0.0' }, + { + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true } + } + } + ); +} + +describe('createMcpHandler — subscriptions/listen', () => { + it('serves listen at the entry, consulting the factory only for its declared capabilities', async () => { + let factoryCalls = 0; + let connectCalls = 0; + let closeCalls = 0; + const handler = createMcpHandler( + () => { + factoryCalls++; + const s = new McpServer({ name: 's', version: '1' }); + const { connect, close } = s; + s.connect = tx => { + connectCalls++; + return connect.call(s, tx); + }; + s.close = () => { + closeCalls++; + return close.call(s); + }; + return s; + }, + { keepAliveMs: 0 } + ); + const response = await handler.fetch(listenRequest(1, { toolsListChanged: true })); + expect(response.status).toBe(200); + const [ack] = await readMessages(response, 1); + // The factory is consulted exactly once (capabilities probe only); the + // instance is never connected and is closed immediately after the + // capabilities read so a factory-allocated resource cannot leak. + expect(factoryCalls).toBe(1); + expect(connectCalls).toBe(0); + expect(closeCalls).toBe(1); + expect((ack as { method: string }).method).toBe('notifications/subscriptions/acknowledged'); + await handler.close(); + }); + + it('ack is the first frame, stamped with the listen id verbatim, carrying the honored subset', async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + const response = await handler.fetch(listenRequest('sub-42', { toolsListChanged: true, promptsListChanged: false })); + const [ack] = await readMessages(response, 1); + expect(ack).toEqual({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 'sub-42' }, notifications: { toolsListChanged: true } } + }); + await handler.close(); + }); + + it('delivers only opted-in change types, each stamped with the subscription id', async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + const response = await handler.fetch(listenRequest(7, { toolsListChanged: true, resourceSubscriptions: ['file:///a'] })); + // Publish before reading: a stream that did NOT opt in to prompts must + // never see the prompts notification (provably-never-delivered). + handler.notify.promptsChanged(); + handler.notify.toolsChanged(); + handler.notify.resourceUpdated('file:///b'); + handler.notify.resourceUpdated('file:///a'); + const messages = (await readMessages(response, 3)) as { method: string; params: Record }[]; + expect(messages.map(m => m.method)).toEqual([ + 'notifications/subscriptions/acknowledged', + 'notifications/tools/list_changed', + 'notifications/resources/updated' + ]); + expect(messages[2]!.params).toEqual({ _meta: { [SUBSCRIPTION_ID_META_KEY]: 7 }, uri: 'file:///a' }); + for (const m of messages) { + expect((m.params['_meta'] as Record)[SUBSCRIPTION_ID_META_KEY]).toBe(7); + } + await handler.close(); + }); + + it("refuses pre-ack with -32603 'Subscription limit reached' when at capacity", async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0, maxSubscriptions: 1 }); + const first = await handler.fetch(listenRequest(1, { toolsListChanged: true })); + expect(first.headers.get('Content-Type')).toBe('text/event-stream'); + const second = await handler.fetch(listenRequest(2, { toolsListChanged: true })); + expect(second.headers.get('Content-Type')).toContain('application/json'); + const body = (await second.json()) as { error: { code: number; message: string }; id: unknown }; + expect(body.error.code).toBe(-32_603); + expect(body.error.message).toBe('Subscription limit reached'); + expect(body.id).toBe(2); + await first.body!.cancel(); + await handler.close(); + }); + + it("rejects with -32602 when params.notifications is absent (spec marks 'notifications' REQUIRED)", async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'subscriptions/listen', params: { _meta: ENVELOPE } }) + }) + ); + expect(response.headers.get('Content-Type')).toContain('application/json'); + const body = (await response.json()) as { error: { code: number; message: string }; id: unknown }; + expect(body.error.code).toBe(-32_602); + expect(body.error.message).toContain("'notifications' is required"); + expect(body.id).toBe(9); + await handler.close(); + }); + + it('handler.close() tears down every open listen stream (HTTP teardown is stream close)', async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + const response = await handler.fetch(listenRequest(1, { toolsListChanged: true })); + const reader = response.body!.getReader(); + // First frame is the ack. + await reader.read(); + await handler.close(); + // Stream-close termination: the read loop ends with no result frame. + let sawResult = false; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const text = new TextDecoder().decode(value); + if (text.includes('"result"')) sawResult = true; + } + expect(sawResult).toBe(false); + }); + + it('legacy-classified listen never reaches the entry listen router (no ack delivered)', async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + // No envelope claim → classified legacy → dispatched through the + // stateless fallback's Server, where `subscriptions/listen` is not in + // the 2025 registry → −32601 in-band (the legacy transport may stream + // it as a single SSE frame). + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'subscriptions/listen', + params: { notifications: { toolsListChanged: true } } + }) + }) + ); + const text = await response.text(); + expect(text).not.toContain('notifications/subscriptions/acknowledged'); + expect(text).toContain('-32601'); + await handler.close(); + }); +}); diff --git a/packages/server/test/server/discover.test.ts b/packages/server/test/server/discover.test.ts index c2b96da595..f4bdbc27c6 100644 --- a/packages/server/test/server/discover.test.ts +++ b/packages/server/test/server/discover.test.ts @@ -4,9 +4,9 @@ * - the handler is installed ONLY when the server's supported-versions list * carries a modern (2026-07-28+) revision; default servers keep answering * -32601 byte-identically to the deployed fleet - * - the advertisement is modern-only (DV-30) and excludes the - * listChanged/subscribe-class capabilities (A11 rider — until the - * subscriptions/listen milestone lands) + * - the advertisement is modern-only (DV-30) and carries the + * listChanged/subscribe-class capabilities (the spec keeps the bits at + * 2026-07-28; A11 rider discharged with the subscriptions/listen milestone) * - counter-offer ordering: with era-aware list semantics in place, a legacy * initialize can never meet a modern version string at the counter-offer * site, even when the supported list carries one — the guard that must hold @@ -129,7 +129,7 @@ describe('discover advertisement constraints', () => { await server.close(); }); - it('excludes listChanged/subscribe-class capabilities (A11 rider, until subscriptions/listen lands)', async () => { + it('advertises listChanged/subscribe-class capabilities (A11 rider discharged: subscriptions/listen is served)', async () => { const server = new Server( { name: 'test', version: '1.0.0' }, { @@ -147,22 +147,21 @@ describe('discover advertisement constraints', () => { if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); const result = DiscoverResultSchema.parse(response.result) as DiscoverResult; - expect(result.capabilities.tools).toEqual({}); - expect(result.capabilities.prompts).toEqual({}); - expect(result.capabilities.resources).toEqual({}); + expect(result.capabilities.tools).toEqual({ listChanged: true }); + expect(result.capabilities.prompts).toEqual({ listChanged: true }); + expect(result.capabilities.resources).toEqual({ listChanged: true, subscribe: true }); expect(result.capabilities.logging).toEqual({}); expect(result.capabilities.completions).toEqual({}); - expect(JSON.stringify(result.capabilities)).not.toContain('listChanged'); - expect(JSON.stringify(result.capabilities)).not.toContain('subscribe'); await server.close(); }); it('discoverAdvertisedCapabilities is pure and leaves the initialize advertisement untouched', async () => { const capabilities = { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }; - const stripped = discoverAdvertisedCapabilities(capabilities); - expect(stripped).toEqual({ tools: {}, resources: {} }); - // No mutation of the input. + const advertised = discoverAdvertisedCapabilities(capabilities); + expect(advertised).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }); + // No mutation / aliasing of the input. + expect(advertised).not.toBe(capabilities); expect(capabilities).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }); // The legacy initialize advertisement still carries the full capability set. diff --git a/packages/server/test/server/serveStdioListen.test.ts b/packages/server/test/server/serveStdioListen.test.ts new file mode 100644 index 0000000000..d800a21b2c --- /dev/null +++ b/packages/server/test/server/serveStdioListen.test.ts @@ -0,0 +1,240 @@ +/** + * `serveStdio` — entry-handled `subscriptions/listen` on the stdio entry. + * + * Covers ack-first on the single channel, subscription-id stamping, the + * pinned instance's send*ListChanged() feeding the connection's listen + * router (era-gated; legacy unchanged), inbound cancel hardening, and the + * stdio teardown MUST (one notifications/cancelled per subscription id). + */ +import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + PROTOCOL_VERSION_META_KEY, + SUBSCRIPTION_ID_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { StdioListenRouter } from '../../src/server/listenRouter.js'; +import { McpServer } from '../../src/server/mcp.js'; +import { serveStdio } from '../../src/server/serveStdio.js'; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'stdio-listen-test', version: '1' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function listenReq(id: number | string, filter: Record): JSONRPCRequest { + return { jsonrpc: '2.0', id, method: 'subscriptions/listen', params: { _meta: ENVELOPE, notifications: filter } }; +} + +async function bootModern(options?: { maxSubscriptions?: number }) { + const [peerTx, wireTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + peerTx.onmessage = m => inbound.push(m); + await peerTx.start(); + + let server!: McpServer; + const handle = serveStdio( + () => { + server = new McpServer({ name: 's', version: '1' }); + server.registerTool('a', { inputSchema: z.object({}) }, async () => ({ content: [] })); + return server; + }, + { transport: wireTx as Transport, ...options } + ); + // Pin modern with a tools/list (any non-discover enveloped request). + await peerTx.send({ jsonrpc: '2.0', id: 'pin', method: 'tools/list', params: { _meta: ENVELOPE } }); + await new Promise(r => setTimeout(r, 10)); + inbound.length = 0; + const flush = () => new Promise(r => setTimeout(r, 10)); + const send = (m: JSONRPCRequest | JSONRPCNotification) => peerTx.send(m); + return { handle, server: () => server, inbound, send, flush }; +} + +describe('serveStdio — subscriptions/listen', () => { + it('ack is the first message after a listen request, stamped with the listen id verbatim', async () => { + const { handle, inbound, send, flush } = await bootModern(); + await send(listenReq(7, { toolsListChanged: true })); + await flush(); + expect(inbound).toHaveLength(1); + expect(inbound[0]).toEqual({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 7 }, notifications: { toolsListChanged: true } } + }); + await handle.close(); + }); + + it("the pinned instance's sendToolListChanged() reaches only opted-in subscriptions, stamped per stream", async () => { + const { handle, server, inbound, send, flush } = await bootModern(); + await send(listenReq(1, { toolsListChanged: true })); + await send(listenReq(2, { promptsListChanged: true })); + await flush(); + inbound.length = 0; + // Mutate registration → McpServer fires sendToolListChanged(). + server().registerTool('b', { inputSchema: z.object({}) }, async () => ({ content: [] })); + await flush(); + expect(inbound).toHaveLength(1); + const note = inbound[0] as JSONRPCNotification; + expect(note.method).toBe('notifications/tools/list_changed'); + expect((note.params as { _meta: Record })._meta[SUBSCRIPTION_ID_META_KEY]).toBe(1); + await handle.close(); + }); + + it('drops change notifications no subscription opted in to (modern era never delivers unsolicited)', async () => { + const { handle, server, inbound, send, flush } = await bootModern(); + await send(listenReq(1, { promptsListChanged: true })); + await flush(); + inbound.length = 0; + server().sendToolListChanged(); + await flush(); + expect(inbound).toEqual([]); + await handle.close(); + }); + + it('inbound notifications/cancelled tears the subscription down; nothing further delivered (post-cancel hardening)', async () => { + const { handle, server, inbound, send, flush } = await bootModern(); + await send(listenReq(5, { toolsListChanged: true })); + await flush(); + inbound.length = 0; + await send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 5 } }); + await flush(); + server().sendToolListChanged(); + await flush(); + expect(inbound).toEqual([]); + await handle.close(); + }); + + it('handle.close() emits one notifications/cancelled per active subscription id (stdio teardown MUST)', async () => { + const { handle, inbound, send, flush } = await bootModern(); + await send(listenReq('s1', { toolsListChanged: true })); + await send(listenReq('s2', { promptsListChanged: true })); + await flush(); + inbound.length = 0; + await handle.close(); + const cancelled = inbound.filter(m => (m as JSONRPCNotification).method === 'notifications/cancelled'); + expect(cancelled.map(m => (m as JSONRPCNotification).params)).toEqual([{ requestId: 's1' }, { requestId: 's2' }]); + // No JSON-RPC result for the listen ids — termination is the cancelled notification only. + expect(inbound.some(m => 'result' in m)).toBe(false); + }); + + it('refuses pre-ack with -32603 when at capacity', async () => { + const { handle, inbound, send, flush } = await bootModern({ maxSubscriptions: 1 }); + await send(listenReq(1, { toolsListChanged: true })); + await send(listenReq(2, { toolsListChanged: true })); + await flush(); + const err = inbound.find(m => 'error' in m) as { id: unknown; error: { code: number; message: string } } | undefined; + expect(err?.id).toBe(2); + expect(err?.error.code).toBe(-32_603); + expect(err?.error.message).toBe('Subscription limit reached'); + await handle.close(); + }); + + it("narrows the acknowledged filter against the pinned instance's declared capabilities", async () => { + // bootModern's factory registers a tool (so tools.listChanged is + // advertised) but no prompts/resources: a listen requesting all + // listChanged types must see only toolsListChanged honored. + const { handle, inbound, send, flush } = await bootModern(); + await send(listenReq(42, { toolsListChanged: true, promptsListChanged: true, resourcesListChanged: true })); + await flush(); + expect(inbound).toHaveLength(1); + expect(inbound[0]).toEqual({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 42 }, notifications: { toolsListChanged: true } } + }); + await handle.close(); + }); + + it('rejects an entry-handled listen with -32602 when the per-request envelope is absent', async () => { + const { handle, inbound, send, flush } = await bootModern(); + // Connection is pinned modern; a later listen without the envelope + // claim must be rejected at the entry's envelope rung (no ack written). + await send({ jsonrpc: '2.0', id: 8, method: 'subscriptions/listen', params: { notifications: { toolsListChanged: true } } }); + await flush(); + expect(inbound).toHaveLength(1); + const err = inbound[0] as { id: unknown; error: { code: number; message: string } }; + expect(err.id).toBe(8); + expect(err.error.code).toBe(-32_602); + expect(err.error.message).toContain('Invalid _meta envelope'); + expect(inbound.some(m => (m as JSONRPCNotification).method === 'notifications/subscriptions/acknowledged')).toBe(false); + await handle.close(); + }); + + it('rejects an entry-handled listen claiming a revision the entry does not serve (unsupported-revision)', async () => { + const { handle, inbound, send, flush } = await bootModern(); + await send({ + jsonrpc: '2.0', + id: 9, + method: 'subscriptions/listen', + params: { + _meta: { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2099-01-01' }, + notifications: { toolsListChanged: true } + } + }); + await flush(); + expect(inbound).toHaveLength(1); + const err = inbound[0] as { id: unknown; error: { code: number; message: string; data?: unknown } }; + expect(err.id).toBe(9); + // Same shape the opening classifier produces for an unsupported + // revision (ProtocolErrorCode.UnsupportedProtocolVersion). + expect(err.error.code).toBe(-32_004); + expect(err.error.data).toMatchObject({ requested: '2099-01-01' }); + expect(inbound.some(m => (m as JSONRPCNotification).method === 'notifications/subscriptions/acknowledged')).toBe(false); + await handle.close(); + }); + + it('legacy-era pinned connection passes change notifications through unchanged (2025 unsolicited delivery)', async () => { + const [peerTx, wireTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + peerTx.onmessage = m => inbound.push(m); + await peerTx.start(); + let server!: McpServer; + const handle = serveStdio( + () => { + server = new McpServer({ name: 's', version: '1' }); + server.registerTool('a', { inputSchema: z.object({}) }, async () => ({ content: [] })); + return server; + }, + { transport: wireTx as Transport } + ); + // Legacy opening. + await peerTx.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '1' } } + }); + await new Promise(r => setTimeout(r, 10)); + await peerTx.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await new Promise(r => setTimeout(r, 10)); + inbound.length = 0; + server.sendToolListChanged(); + await new Promise(r => setTimeout(r, 10)); + // 2025 unsolicited delivery: passed straight through, NO subscription-id stamp. + expect(inbound).toHaveLength(1); + const note = inbound[0] as JSONRPCNotification; + expect(note.method).toBe('notifications/tools/list_changed'); + expect((note.params as { _meta?: unknown } | undefined)?._meta).toBeUndefined(); + await handle.close(); + }); +}); + +describe('StdioListenRouter — capability gate', () => { + it('serve() throws before setServerCapabilities() (refuses to honor a filter without capabilities)', () => { + const router = new StdioListenRouter(); + expect(() => router.serve(listenReq(1, { toolsListChanged: true }))).toThrow(/before setServerCapabilities/); + // Once capabilities are supplied (as serveStdio does at modern-instance + // construction) the same call succeeds and narrows. + router.setServerCapabilities({ tools: { listChanged: true } }); + const ack = router.serve(listenReq(1, { toolsListChanged: true, promptsListChanged: true })); + expect(ack).toMatchObject({ + method: 'notifications/subscriptions/acknowledged', + params: { notifications: { toolsListChanged: true } } + }); + }); +}); diff --git a/packages/server/test/server/serverEventBus.test.ts b/packages/server/test/server/serverEventBus.test.ts new file mode 100644 index 0000000000..6f9feb1d7b --- /dev/null +++ b/packages/server/test/server/serverEventBus.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; + +import { + InMemoryServerEventBus, + createServerNotifier, + honoredSubset, + listenFilterAccepts, + serverEventToNotification +} from '../../src/server/serverEventBus.js'; + +describe('listenFilterAccepts', () => { + it('accepts only the change types the filter explicitly opted in to', () => { + const filter = { toolsListChanged: true as const }; + expect(listenFilterAccepts(filter, { kind: 'tools_list_changed' })).toBe(true); + expect(listenFilterAccepts(filter, { kind: 'prompts_list_changed' })).toBe(false); + expect(listenFilterAccepts(filter, { kind: 'resources_list_changed' })).toBe(false); + expect(listenFilterAccepts(filter, { kind: 'resource_updated', uri: 'file:///x' })).toBe(false); + }); + + it('treats false and absent identically (opt-in only on true)', () => { + expect(listenFilterAccepts({ toolsListChanged: false }, { kind: 'tools_list_changed' })).toBe(false); + expect(listenFilterAccepts({}, { kind: 'tools_list_changed' })).toBe(false); + }); + + it('matches resource_updated only on the exact opted-in URI', () => { + const filter = { resourceSubscriptions: ['file:///project/config.json'] }; + expect(listenFilterAccepts(filter, { kind: 'resource_updated', uri: 'file:///project/config.json' })).toBe(true); + expect(listenFilterAccepts(filter, { kind: 'resource_updated', uri: 'file:///other' })).toBe(false); + // Empty list = no resource updates accepted. + expect(listenFilterAccepts({ resourceSubscriptions: [] }, { kind: 'resource_updated', uri: 'file:///x' })).toBe(false); + // Absent = no resource updates accepted. + expect(listenFilterAccepts({}, { kind: 'resource_updated', uri: 'file:///x' })).toBe(false); + }); + + it('an empty filter accepts nothing (un-requested types are provably never delivered)', () => { + const filter = {}; + expect(listenFilterAccepts(filter, { kind: 'tools_list_changed' })).toBe(false); + expect(listenFilterAccepts(filter, { kind: 'prompts_list_changed' })).toBe(false); + expect(listenFilterAccepts(filter, { kind: 'resources_list_changed' })).toBe(false); + expect(listenFilterAccepts(filter, { kind: 'resource_updated', uri: 'file:///x' })).toBe(false); + }); +}); + +describe('honoredSubset', () => { + it('keeps only explicitly-true / non-empty fields', () => { + expect(honoredSubset({ toolsListChanged: true, promptsListChanged: false, resourceSubscriptions: ['file:///a'] })).toEqual({ + toolsListChanged: true, + resourceSubscriptions: ['file:///a'] + }); + }); + + it('returns an empty object for an all-absent / all-false filter', () => { + expect(honoredSubset({})).toEqual({}); + expect(honoredSubset({ toolsListChanged: false, resourceSubscriptions: [] })).toEqual({}); + }); + + it('does not alias the requested resourceSubscriptions array', () => { + const requested = { resourceSubscriptions: ['file:///a'] }; + const honored = honoredSubset(requested); + requested.resourceSubscriptions.push('file:///b'); + expect(honored.resourceSubscriptions).toEqual(['file:///a']); + }); + + it('narrows against the supplied server capabilities', () => { + const requested = { + toolsListChanged: true as const, + promptsListChanged: true as const, + resourcesListChanged: true as const, + resourceSubscriptions: ['file:///a'] + }; + // Only tools.listChanged advertised → only toolsListChanged honored. + expect(honoredSubset(requested, { tools: { listChanged: true } })).toEqual({ toolsListChanged: true }); + // resources.subscribe gates resourceSubscriptions; resources.listChanged gates resourcesListChanged. + expect(honoredSubset(requested, { resources: { subscribe: true } })).toEqual({ resourceSubscriptions: ['file:///a'] }); + expect(honoredSubset(requested, { resources: { listChanged: true } })).toEqual({ resourcesListChanged: true }); + // No relevant capability advertised → empty. + expect(honoredSubset(requested, {})).toEqual({}); + // Omitted capabilities → requested set honored as-is (back-compat). + expect(honoredSubset(requested)).toEqual(requested); + }); +}); + +describe('serverEventToNotification', () => { + it('maps each event kind onto its wire method', () => { + expect(serverEventToNotification({ kind: 'tools_list_changed' })).toEqual({ method: 'notifications/tools/list_changed' }); + expect(serverEventToNotification({ kind: 'prompts_list_changed' })).toEqual({ method: 'notifications/prompts/list_changed' }); + expect(serverEventToNotification({ kind: 'resources_list_changed' })).toEqual({ + method: 'notifications/resources/list_changed' + }); + expect(serverEventToNotification({ kind: 'resource_updated', uri: 'file:///a' })).toEqual({ + method: 'notifications/resources/updated', + params: { uri: 'file:///a' } + }); + }); +}); + +describe('InMemoryServerEventBus', () => { + it('delivers a published event to every registered listener', () => { + const bus = new InMemoryServerEventBus(); + const a: string[] = []; + const b: string[] = []; + bus.subscribe(e => a.push(e.kind)); + bus.subscribe(e => b.push(e.kind)); + bus.publish({ kind: 'tools_list_changed' }); + expect(a).toEqual(['tools_list_changed']); + expect(b).toEqual(['tools_list_changed']); + }); + + it('unsubscribe is idempotent and stops further delivery', () => { + const bus = new InMemoryServerEventBus(); + const seen: string[] = []; + const off = bus.subscribe(e => seen.push(e.kind)); + bus.publish({ kind: 'tools_list_changed' }); + off(); + off(); + bus.publish({ kind: 'prompts_list_changed' }); + expect(seen).toEqual(['tools_list_changed']); + expect(bus.listenerCount).toBe(0); + }); + + it('a throwing listener does not stop delivery to peers; error surfaces via onerror', () => { + const errors: Error[] = []; + const bus = new InMemoryServerEventBus(e => errors.push(e)); + const seen: string[] = []; + bus.subscribe(() => { + throw new Error('boom'); + }); + bus.subscribe(e => seen.push(e.kind)); + bus.publish({ kind: 'tools_list_changed' }); + expect(seen).toEqual(['tools_list_changed']); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toBe('boom'); + }); + + it('createServerNotifier publishes the matching event kind', () => { + const bus = new InMemoryServerEventBus(); + const seen: unknown[] = []; + bus.subscribe(e => seen.push(e)); + const notify = createServerNotifier(bus); + notify.toolsChanged(); + notify.promptsChanged(); + notify.resourcesChanged(); + notify.resourceUpdated('file:///a'); + expect(seen).toEqual([ + { kind: 'tools_list_changed' }, + { kind: 'prompts_list_changed' }, + { kind: 'resources_list_changed' }, + { kind: 'resource_updated', uri: 'file:///a' } + ]); + }); +}); diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index 5c30ea0ade..e23ffbe1b3 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -67,6 +67,10 @@ client: server: # --- Carried-forward scenarios (also run by the 2025 legs) --- + # WARNING-only: see the matching entry in expected-failures.yaml — the + # sep-2575 list_changed-on-listen SHOULD checks; fixture armed in a + # follow-up change. + - server-stateless # Pre-existing fixture/baseline bug: the fixture tool's schema is a plain # Zod object with none of the JSON Schema 2020-12 keywords the scenario # checks; it fails identically at 2025 in `--suite all` (not a 2026-path diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index c5ab22325a..3b76bd94bf 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -49,6 +49,14 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) --- + # WARNING-only: with the listChanged capability now advertised in + # server/discover, server-stateless runs three additional SHOULD-level + # checks (sep-2575-server-sends-{tools,prompts,resources}-list-changed-on- + # subscription) that the conformance fixture cannot yet satisfy because it + # is not wired to publish list_changed events to listen streams. The + # fixture is armed in a follow-up change, after which this entry burns + # down. + - server-stateless # SEP-2243 (HTTP header standardization): the reject cells the SDK does # answer now use -32001 (HeaderMismatch), but missing-header enforcement # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, diff --git a/test/integration/test/client/discoverRoundtrip.test.ts b/test/integration/test/client/discoverRoundtrip.test.ts index cdf551d60e..c79e148682 100644 --- a/test/integration/test/client/discoverRoundtrip.test.ts +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -80,8 +80,9 @@ describe('server/discover round-trip against a modern server', () => { expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); expect(client.getServerVersion()).toEqual({ name: 'dual-era-server', version: '2.0.0' }); expect(client.getInstructions()).toBe('dual era'); - // The advertisement excludes listChanged-class capabilities, visible end to end. - expect(client.getServerCapabilities()).toEqual({ tools: {} }); + // The advertisement carries listChanged-class capabilities now that + // the serving entries serve subscriptions/listen, visible end to end. + expect(client.getServerCapabilities()).toEqual({ tools: { listChanged: true } }); expect(bodies.some(b => b.includes('"initialize"'))).toBe(false); expect(bodies[0]).toContain('server/discover'); diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index fc3619277e..ae2a30aac8 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -8,7 +8,12 @@ import type { Server as HttpServer } from 'node:http'; import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + SUBSCRIPTION_ID_META_KEY +} from '@modelcontextprotocol/core'; import type { CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; @@ -139,3 +144,70 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { expect(body.id).toBe(1); }); }); + +describe('createMcpHandler over HTTP — subscriptions/listen honored filter', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + it("drops a requested type the server's declared capabilities do not advertise", async () => { + // Factory declares tools.listChanged but NOT prompts.listChanged: a listen + // request that asks for both must be acknowledged with prompts dropped — + // the honored filter is narrowed against the per-serve instance's + // capabilities, not echoed verbatim. + const handler = createMcpHandler( + () => new McpServer({ name: 'caps-gated', version: '1' }, { capabilities: { tools: { listChanged: true } } }), + { keepAliveMs: 0 } + ); + const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await handler.close(); + httpServer.close(); + }); + + const response = await fetch(new URL('/mcp', baseUrl), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'sub-1', + method: 'subscriptions/listen', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'integration-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }, + notifications: { toolsListChanged: true, promptsListChanged: true } + } + }) + }); + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + + // Read the first SSE frame (the ack) and stop. + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let ack: { method: string; params: { notifications: Record; _meta: Record } } | undefined; + while (ack === undefined) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const idx = buffer.indexOf('\n\n'); + if (idx !== -1) { + const frame = buffer.slice(0, idx); + const dataLine = frame.split('\n').find(l => l.startsWith('data: ')); + if (dataLine) ack = JSON.parse(dataLine.slice(6)); + } + } + await reader.cancel(); + + expect(ack?.method).toBe('notifications/subscriptions/acknowledged'); + expect(ack?.params.notifications).toEqual({ toolsListChanged: true }); + expect(ack?.params.notifications).not.toHaveProperty('promptsListChanged'); + expect(ack?.params._meta[SUBSCRIPTION_ID_META_KEY]).toBe('sub-1'); + }); +}); From 699d6a6a50f74c7ab2018e386d476be397ce7afc Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:57:12 +0100 Subject: [PATCH 30/37] feat(client): Client.listen() and listChanged auto-open on modern connections (#2322) --- .changeset/subscriptions-listen-client.md | 6 + docs/migration.md | 23 +- packages/client/src/client/client.ts | 526 +++++++++- packages/client/src/client/streamableHttp.ts | 178 +++- packages/client/src/index.ts | 2 +- packages/client/test/client/listen.test.ts | 936 ++++++++++++++++++ .../client/test/client/streamableHttp.test.ts | 401 ++++++++ packages/core/src/shared/protocol.ts | 23 +- packages/core/src/shared/transport.ts | 20 + packages/core/src/types/types.ts | 2 +- .../core/src/wire/rev2026-07-28/registry.ts | 4 +- test/e2e/CLAUDE.md | 2 +- test/e2e/requirements.ts | 47 + test/e2e/scenarios/subscriptions.test.ts | 161 +++ 14 files changed, 2287 insertions(+), 44 deletions(-) create mode 100644 .changeset/subscriptions-listen-client.md create mode 100644 packages/client/test/client/listen.test.ts create mode 100644 test/e2e/scenarios/subscriptions.test.ts diff --git a/.changeset/subscriptions-listen-client.md b/.changeset/subscriptions-listen-client.md new file mode 100644 index 0000000000..3cc5c10757 --- /dev/null +++ b/.changeset/subscriptions-listen-client.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close(), closed }`. `closed` is a `Promise<'local' | 'remote'>` that resolves exactly once when the subscription terminates (`'local'` = you called `close()`; `'remote'` = the server cancelled, the stream ended, or the transport dropped — re-listen if you still want events) and never rejects. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is era-transparent for consumers that already register those. `close()` aborts the listen request's stream (where the transport supports it) and sends `notifications/cancelled` referencing the listen id — both, on every transport; no automatic re-listen. On a 2025-era connection `listen()` throws a typed `MethodNotSupportedByProtocolVersion` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities; auto-open is skipped (`client.autoOpenedSubscription` stays `undefined`) when that intersection is empty; otherwise the auto-opened subscription is exposed at `client.autoOpenedSubscription`. `TransportSendOptions` gains `requestSignal` (per-request abort) and `onRequestStreamEnd` (fires when a per-request response stream ends or errors for any non-deliberate reason) on the Streamable HTTP transport. diff --git a/docs/migration.md b/docs/migration.md index 445304b986..2e79e8964b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1159,6 +1159,23 @@ Resolution is per field, most specific author first: for each of `ttlMs` and `ca per-resource hint that sets only one field never suppresses the other field configured at the operation level. Configured hints are validated when they are configured — an invalid `ttlMs` (negative or non-integer) or `cacheScope` throws a `RangeError`. Responses on 2025-era connections never carry these fields, with or without configuration. +### `subscriptions/listen` (2026-07-28): change-notification streams replace unsolicited delivery + +The 2026-07-28 revision delivers `tools/prompts/resources` `list_changed` and `resources/updated` only on a `subscriptions/listen` stream the client opened — the server never sends an un-requested notification type. Both halves ship: + +**Server side.** Nothing to register: the serving entries handle `subscriptions/listen` themselves. `createMcpHandler` returns `.notify.{toolsChanged, promptsChanged, resourcesChanged, resourceUpdated(uri)}` typed publish sugar over an in-process bus (supply your own +`ServerEventBus` for multi-process deployments). On stdio, `serveStdio` routes the pinned instance's existing `send*ListChanged()` calls onto the active subscriptions automatically. The 2025-era unsolicited delivery model is unchanged on legacy connections. + +```typescript +const handler = createMcpHandler(() => buildServer()); +// after a tool registration changes: +handler.notify.toolsChanged(); +``` + +**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers +fire on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if you still want events); change notifications dispatch to the existing `setNotificationHandler` +registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead. + ### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver The 2026-07-28 revision removes the server→client JSON-RPC request channel: servers obtain client input (elicitation, sampling, roots) **in-band**, by answering `tools/call`, `prompts/get`, or `resources/read` with an `input_required` result that embeds the requests, and the @@ -1192,9 +1209,9 @@ has: only `tools/call` has a catch-all that wraps handler failures into `isError **`requestState` is untrusted input — protect it yourself.** `inputRequired({ requestState })` lets a server round-trip opaque state through the client instead of holding it in memory. The SDK treats it as an opaque string end to end: the client echoes it back byte-exact and never parses it, and the server sees the echoed value raw at `ctx.mcpReq.requestState`. The specification's requirement is the consumer's obligation: the value comes back as **attacker-controlled input**, so if it influences authorization, resource access, or business logic you -MUST integrity-protect it when minting it (for example HMAC or AEAD over the payload, bound to the principal, the originating method/parameters, and an expiry) and MUST reject state that fails verification on re-entry. The SDK does not provide or apply any sealing of its own, -but it does provide the place to put your verification: configure `ServerOptions.requestState.verify`, and the seam runs it before the handler whenever `requestState` is present — a thrown rejection answers the client with a frozen `-32602` (above the tool funnel, so it is a -real JSON-RPC error rather than an `isError` result). See `examples/server/src/multiRoundTrip.ts` for a worked HMAC example. +MUST integrity-protect it when minting it (for example HMAC or AEAD over the payload, bound to the principal, the originating method/parameters, and an expiry) and MUST reject state that fails verification on re-entry. The SDK does not provide or apply any sealing of its own, but +it does provide the place to put your verification: configure `ServerOptions.requestState.verify`, and the seam runs it before the handler whenever `requestState` is present — a thrown rejection answers the client with a frozen `-32602` (above the tool funnel, so it is a real +JSON-RPC error rather than an `isError` result). See `examples/server/src/multiRoundTrip.ts` for a worked HMAC example. **Client side — auto-fulfilment by default.** When a call to `tools/call`, `prompts/get`, or `resources/read` on a 2026-07-28 connection answers `input_required`, the client fulfils the embedded requests through the same handlers registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | 'roots/list', …)` and retries the original request (fresh request id, `inputResponses`, byte-exact `requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). `client.callTool()` and its siblings diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 4eef2f2ec8..e6ae442893 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -17,6 +17,7 @@ import type { InputRequiredOptions, JSONRPCNotification, JSONRPCRequest, + JSONRPCResponse, JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, @@ -45,6 +46,7 @@ import type { ServerCapabilities, StandardSchemaV1, SubscribeRequest, + SubscriptionFilter, Tool, Transport, UnsubscribeRequest @@ -57,6 +59,7 @@ import { CreateMessageResultWithToolsSchema, DEFAULT_REQUEST_TIMEOUT_MSEC, DiscoverResultSchema, + isJSONRPCErrorResponse, isJSONRPCRequest, isModernProtocolVersion, legacyProtocolVersions, @@ -70,7 +73,9 @@ import { resolveInputRequiredDriverConfig, runInputRequiredFlow, SdkError, - SdkErrorCode + SdkErrorCode, + SUBSCRIPTION_ID_META_KEY, + SubscriptionFilterSchema } from '@modelcontextprotocol/core'; import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js'; @@ -252,6 +257,48 @@ export type ClientOptions = ProtocolOptions & { listChanged?: ListChangedHandlers; }; +/** + * A handle to an open `subscriptions/listen` stream (protocol revision + * 2026-07-28). Change notifications delivered on the stream dispatch to the + * existing {@linkcode Client.setNotificationHandler} registrations. + */ +export interface McpSubscription { + /** + * The subset of the requested filter the server agreed to honor (from + * `notifications/subscriptions/acknowledged`). + */ + readonly honoredFilter: SubscriptionFilter; + /** + * Tears the subscription down. Idempotent. Aborts the listen request's + * stream (where the transport supports it) AND sends + * `notifications/cancelled` referencing the listen request id — both, + * always, so close works on any transport. + */ + close(): Promise; + /** + * Resolves exactly once when the subscription has terminated. Never + * rejects — this is an observation, not an operation. + * + * - `'local'` — you called {@linkcode close} (or aborted the + * `RequestOptions.signal` you passed to `listen()`). + * - `'remote'` — the server cancelled, the stream ended, or the transport + * dropped. Re-listen if you still want events. + */ + readonly closed: Promise<'local' | 'remote'>; +} + +/** @internal */ +interface ListenStateEntry { + /** + * The single funnel for the per-listen `opening → open → closed` state + * machine. Every transport-level feed source — the `_onnotification` / + * `_onresponse` / `_onclose` overrides, `onRequestStreamEnd`, send + * failure, ack timeout, caller-signal abort, `_resetConnectionState` — + * routes through it. + */ + settle: (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'remote'; error?: Error }) => void; +} + /** * An MCP client on top of a pluggable transport. * @@ -294,11 +341,74 @@ export class Client extends Protocol { private _jsonSchemaValidator: jsonSchemaValidator; private _cachedToolOutputValidators: Map> = new Map(); private _listChangedDebounceTimers: Map> = new Map(); - private _pendingListChangedConfig?: ListChangedHandlers; + /** + * The constructor `listChanged` configuration. Durable across reconnects: + * read fresh on every connect (legacy or modern), never consumed. + */ + private readonly _listChangedConfig?: ListChangedHandlers; private _enforceStrictCapabilities: boolean; private _versionNegotiation?: VersionNegotiationOptions; private _supportedProtocolVersionsOption?: string[]; private _inputRequiredDriverConfig: ResolvedInputRequiredDriverConfig; + /** + * Active subscriptions/listen state, keyed by subscription id (= the + * listen request's JSON-RPC id verbatim). The id is a STRING from a + * Client-owned counter (`'listen:' + N`) — JSON-RPC permits string ids, + * and Protocol's numeric `_requestMessageId` counter only ever issues + * numbers, so listen ids cannot collide with ordinary request ids. + */ + private _listenState = new Map(); + private _nextListenId = 0; + /** The auto-opened subscription backing ClientOptions.listChanged on a modern connection. */ + private _autoOpenedSubscription?: McpSubscription; + + /** + * Clears every per-connection field in one place. Called at the start of + * each fresh (non-resuming) connect and from `close()`, so a stale + * negotiated era / server identity / auto-opened subscription cannot + * survive a reconnect. + */ + private _resetConnectionState(): void { + this._negotiatedProtocolVersion = undefined; + this._serverCapabilities = undefined; + this._serverVersion = undefined; + this._instructions = undefined; + this._autoOpenedSubscription = undefined; + // Settle every live per-listen state machine before clearing the map: + // a fresh connect (or close) on a connection whose prior transport + // never fired onclose would otherwise leave an in-flight listen() + // promise hanging forever. Each entry's settle() deletes only itself + // (Map self-delete during iteration is well-defined). + if (this._listenState.size > 0) { + const reason = new SdkError( + SdkErrorCode.ConnectionClosed, + 'subscriptions/listen: client reconnected or closed; subscription state from the previous connection was reset' + ); + for (const entry of this._listenState.values()) { + entry.settle({ cause: 'remote', error: reason }); + } + } + this._listenState.clear(); + // Debounce timers are connection-scoped: a callback armed on a + // connection that is now gone must not fire onto whatever connection + // (if any) replaces it. + for (const timer of this._listChangedDebounceTimers.values()) { + clearTimeout(timer); + } + this._listChangedDebounceTimers.clear(); + this._cachedToolOutputValidators.clear(); + } + + override async close(): Promise { + try { + await super.close(); + } finally { + // Per-connection state is cleared even when the transport's close + // rejects, so a stale negotiated era / live listen state cannot + // survive a failed close. + this._resetConnectionState(); + } + } /** * Initializes this client with the given name and version information. @@ -319,7 +429,7 @@ export class Client extends Protocol { // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { - this._pendingListChangedConfig = options.listChanged; + this._listChangedConfig = options.listChanged; } } @@ -657,15 +767,15 @@ export class Client extends Protocol { } return; } - // Fresh connect: the negotiated protocol version is connection state — - // a value left over from a previous connection must not survive into a - // new handshake. Clearing it puts the instance back in the - // pre-negotiation phase, so the initialize exchange below rides the - // bootstrap method pins (legacy era) instead of a dead session's era. - // Without this, an instance that once negotiated a modern era could - // never re-run a fresh handshake: `initialize` is physically absent - // from the modern registry. (The resume branch above keeps it instead.) - this._negotiatedProtocolVersion = undefined; + // Fresh connect: per-connection state left over from a previous + // connection must not survive into a new handshake. Clearing it puts + // the instance back in the pre-negotiation phase, so the initialize + // exchange below rides the bootstrap method pins (legacy era) instead + // of a dead session's era. Without this, an instance that once + // negotiated a modern era could never re-run a fresh handshake: + // `initialize` is physically absent from the modern registry. (The + // resume branch above keeps it instead.) + this._resetConnectionState(); await this._legacyHandshake(transport, options); } @@ -731,9 +841,8 @@ export class Client extends Protocol { this._negotiatedProtocolVersion = result.protocolVersion; // Set up list changed handlers now that we know server capabilities - if (this._pendingListChangedConfig) { - this._setupListChangedHandlers(this._pendingListChangedConfig); - this._pendingListChangedConfig = undefined; + if (this._listChangedConfig) { + this._setupListChangedHandlers(this._listChangedConfig); } } catch (error) { // Disconnect if initialization fails. @@ -765,7 +874,7 @@ export class Client extends Protocol { // Fresh connect: stale connection state must not survive into a new // negotiation — every fresh negotiated connect re-runs the probe. - this._negotiatedProtocolVersion = undefined; + this._resetConnectionState(); let result: Awaited>; try { @@ -802,10 +911,90 @@ export class Client extends Protocol { transport.setProtocolVersion(result.version); } // The modern era has no notifications/initialized; list-changed handlers - // are configured straight from the advertised capabilities. - if (this._pendingListChangedConfig) { - this._setupListChangedHandlers(this._pendingListChangedConfig); - this._pendingListChangedConfig = undefined; + // are configured straight from the advertised capabilities. On a modern + // connection the configured handlers are fed by an auto-opened + // subscriptions/listen stream (the modern era never delivers change + // notifications unsolicited); on a legacy connection they fire on the + // 2025-era unsolicited notifications, no listen needed. + if (this._listChangedConfig) { + const config = this._listChangedConfig; + // Compute configured ∩ server-advertised ONCE and use that single + // value for BOTH handler registration and the auto-open filter, so + // a configured-but-not-advertised type is neither subscribed to + // nor handled (the two stay in lockstep). + const advertised = this._serverCapabilities; + const effective: ListChangedHandlers = { + ...(config.tools && advertised?.tools?.listChanged && { tools: config.tools }), + ...(config.prompts && advertised?.prompts?.listChanged && { prompts: config.prompts }), + ...(config.resources && advertised?.resources?.listChanged && { resources: config.resources }) + }; + // Handler registration validates the per-type options and can + // throw on misconfiguration; the modern connection IS established + // at this point and is fully usable without listChanged handlers, + // so a misconfiguration surfaces via onerror and connect resolves + // (matching the auto-open soft-fail posture). When registration + // fails the auto-open is SKIPPED — opening a listen stream for + // types whose handler never registered would consume a server + // slot to deliver notifications nothing handles. + let handlersRegistered = true; + try { + this._setupListChangedHandlers(effective); + } catch (error) { + handlersRegistered = false; + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + const filter: SubscriptionFilter = handlersRegistered + ? { + ...(effective.tools && { toolsListChanged: true as const }), + ...(effective.prompts && { promptsListChanged: true as const }), + ...(effective.resources && { resourcesListChanged: true as const }) + } + : {}; + if (Object.keys(filter).length > 0) { + // A failed auto-open MUST NOT fail connect: the modern + // connection is fully usable without a listen stream (the + // server may not support it, or refuse on capacity). Surface + // via onerror; the consumer can call listen() later. + // + // listen() binds RequestOptions.signal to the SUBSCRIPTION + // lifetime, so connect()'s signal must NOT be forwarded + // verbatim — a connect-scoped `AbortSignal.timeout(30_000)` + // would silently tear the auto-opened stream down the moment + // it fires after connect has resolved. But connect()'s signal + // MUST still cancel the in-connect ack WAIT (otherwise an + // aborted connect blocks here for the full ack timeout). + // Derived one-shot: bound to connect()'s signal only for the + // duration of the listen() await; the listener is removed in + // `finally` so the auto-opened subscription outlives connect's + // signal. + const ackAbort = new AbortController(); + const onConnectAbort = (): void => ackAbort.abort(options?.signal?.reason); + // Handle the already-aborted case (aborted between the + // discover leg resolving and now): the listener never fires + // for a past event. + if (options?.signal?.aborted) onConnectAbort(); + options?.signal?.addEventListener('abort', onConnectAbort); + try { + this._autoOpenedSubscription = await this.listen(filter, { + timeout: options?.timeout, + signal: ackAbort.signal + }); + } catch (error) { + // Connect-signal abort during the ack wait propagates as a + // connect() rejection (caller asked to abort connect). The + // transport is already started, so close it before + // rethrowing — a connect() rejection MUST NOT leave a + // half-open connection. A server-side refusal stays a + // soft onerror (connect succeeds, no listen stream). + if (options?.signal?.aborted) { + await this.close().catch(() => {}); + throw error; + } + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } finally { + options?.signal?.removeEventListener('abort', onConnectAbort); + } + } } } @@ -1134,6 +1323,303 @@ export class Client extends Protocol { return this.request({ method: 'resources/unsubscribe', params }, options); } + /** + * Opens a `subscriptions/listen` stream (protocol revision 2026-07-28). + * + * Resolves once the server's `notifications/subscriptions/acknowledged` + * arrives (the standard request timeout applies to this ack phase). Change + * notifications delivered on the stream are dispatched to the existing + * {@linkcode setNotificationHandler} registrations — the same handlers the + * 2025-era unsolicited notifications fire on a legacy connection — so + * `listen()` is era-transparent for consumers that already register those. + * + * `close()` tears the subscription down by aborting the listen request's + * `requestSignal` (closes the SSE stream where the transport honors it) + * AND sending `notifications/cancelled` referencing the listen request id + * — both, unconditionally, so any spec-compliant server on any transport + * sees the cancel. No automatic re-listen — call `listen()` again to + * re-establish. + * + * On a 2025-era connection this throws a typed + * {@linkcode SdkErrorCode.MethodNotSupportedByProtocolVersion} steering to + * `resources/subscribe` and `ClientOptions.listChanged` (the legacy + * unsolicited delivery model still applies there); no transparent shim. + */ + async listen(filter: SubscriptionFilter, options?: RequestOptions): Promise { + // Connectivity is checked first so a closed instance rejects with + // NotConnected (no setup or ack timer is started); after close(), + // `_resetConnectionState` has also cleared the negotiated era, so the + // era guard alone would surface a misleading + // MethodNotSupportedByProtocolVersion. + if (this.transport === undefined) { + throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + } + const negotiated = this._negotiatedProtocolVersion; + if (negotiated === undefined || !isModernProtocolVersion(negotiated)) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `subscriptions/listen requires a 2026-07-28-era connection (negotiated: ${negotiated ?? 'none'}). ` + + 'On a 2025-era connection, change notifications are delivered unsolicited: use ClientOptions.listChanged ' + + 'and resources/subscribe instead.', + { method: 'subscriptions/listen', protocolVersion: negotiated } + ); + } + + // Honor RequestOptions.signal exactly as request() does: an + // already-aborted signal rejects synchronously before any setup. + options?.signal?.throwIfAborted(); + + const requestAbort = new AbortController(); + // The listen request's JSON-RPC id (= the spec's subscription id + // verbatim). A STRING from a Client-owned counter so it cannot + // collide with Protocol's numeric `_requestMessageId` counter — the + // `_onresponse`/`_onnotification` overrides demux by string-id alone. + const listenId = `listen:${this._nextListenId++}`; + + // Explicit `opening → open → closed` state machine. Every termination + // path — ack-arrives, ack-timeout, server-cancelled, user-close, + // stream-end, transport-close, send-failure — funnels through the + // single `settle` below, which clears the ack timer, transitions + // state, and resolves/rejects the opening promise exactly once. The + // cancelled-before-ack / close-before-ack hangs are impossible by + // construction. + let state: 'opening' | 'open' | 'closed' = 'opening'; + let ackTimer: ReturnType | undefined; + let onCallerAbort: (() => void) | undefined; + let resolveOpening!: (honored: SubscriptionFilter) => void; + let rejectOpening!: (error: Error) => void; + const opening = new Promise((resolve, reject) => { + resolveOpening = resolve; + rejectOpening = reject; + }); + // The McpSubscription.closed observation. Resolved exactly once by + // settle()'s `→ closed` transition; never rejects. When listen() + // itself rejects (pre-ack) there is no McpSubscription to observe it + // on — settle() resolves it anyway so nothing dangles. + let resolveClosed!: (cause: 'local' | 'remote') => void; + const closed = new Promise<'local' | 'remote'>(resolve => { + resolveClosed = resolve; + }); + + const settle = (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'remote'; error?: Error }): void => { + if (state === 'closed') return; + const wasOpening = state === 'opening'; + if (ackTimer !== undefined) { + clearTimeout(ackTimer); + ackTimer = undefined; + } + if ('ack' in outcome) { + // The single `opening → open` transition; an ack after close + // hits the `closed` guard above and is a no-op. + state = 'open'; + resolveOpening(outcome.ack); + return; + } + state = 'closed'; + if (onCallerAbort !== undefined) { + options?.signal?.removeEventListener('abort', onCallerAbort); + } + this._listenState.delete(listenId); + // Abort the per-request signal so an HTTP SSE reader stops on a + // remote-initiated close too (server-cancel / stream-end / + // transport-drop). Idempotent; a no-op on transports that ignore + // requestSignal. wireTeardown() also aborts on the local paths — + // harmless redundancy. + requestAbort.abort(); + resolveClosed(outcome.cause); + if (wasOpening) { + rejectOpening( + outcome.error ?? + new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen closed before the server acknowledged') + ); + } + }; + + // Wire-level teardown for a locally-initiated close (user close, ack + // timeout, caller-signal abort). Transport-agnostic: ALWAYS abort the + // request signal (closes the SSE stream where the transport honors + // `requestSignal` — HTTP does, stdio does not) AND send + // `notifications/cancelled` referencing the listen id (which the + // stdio listen router and any spec-compliant server honor). Sent via + // `notification()` so the modern auto-envelope is attached exactly as + // for every other outbound. Idempotent over HTTP — the cancelled + // notification is a no-op once the stream is gone; correct on every + // other transport. Not called when the server already terminated. + const wireTeardown = async (): Promise => { + requestAbort.abort(); + await this.notification({ method: 'notifications/cancelled', params: { requestId: listenId } }).catch(() => {}); + }; + + const close = async (): Promise => { + if (state === 'closed') return; + settle({ cause: 'local' }); + await wireTeardown(); + }; + + // The per-subscription state is registered BEFORE the request is sent + // so a synchronously-delivered ack (an in-process transport) cannot + // race the registration. + this._listenState.set(listenId, { settle }); + + const ackTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + ackTimer = setTimeout(() => { + settle({ + cause: 'remote', + error: new SdkError(SdkErrorCode.RequestTimeout, 'subscriptions/listen ack timed out', { timeout: ackTimeout }) + }); + void wireTeardown().catch(() => {}); + }, ackTimeout); + + // RequestOptions.signal aborts the subscription at any point in its + // lifecycle (mirrors request()'s cancel path). While `opening`, settle + // rejects the pending listen() promise with the signal's reason; while + // `open`, it transitions to `closed` (`closed` resolves `'local'`) and + // tears the wire down. The listener is removed by `settle()` once the + // subscription has closed. + if (options?.signal) { + const callerSignal = options.signal; + onCallerAbort = () => { + if (state === 'closed') return; + const reason = callerSignal.reason; + settle({ cause: 'local', error: reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted')) }); + void wireTeardown().catch(() => {}); + }; + callerSignal.addEventListener('abort', onCallerAbort, { once: true }); + } + + // Send the listen request directly on the transport. The `_meta` + // envelope is built via the same `_outboundMetaEnvelope()` seam every + // other outbound uses (so a future envelope key cannot silently + // diverge here). `onRequestStreamEnd` feeds the per-request stream's + // non-deliberate end into the state machine on transports that open + // one (Streamable HTTP); stdio/InMemory ignore it. + const jsonrpcRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: listenId, + method: 'subscriptions/listen', + params: { _meta: { ...this._outboundMetaEnvelope() }, notifications: filter } + }; + try { + await this.transport.send(jsonrpcRequest, { + requestSignal: requestAbort.signal, + onRequestStreamEnd: () => settle({ cause: 'remote', error: new Error('subscriptions/listen: stream ended') }) + }); + } catch (error) { + // Synchronous OR awaited send failure (including a per-request + // abort fired before response headers — `streamableHttp._send` + // rethrows with onerror suppressed). `settle()` is idempotent so + // a locally-aborted send hitting this path after `close()` is a + // no-op. + settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) }); + } + + const honored = await opening; + return { honoredFilter: honored, close, closed }; + } + + /** + * The subscription auto-opened by `ClientOptions.listChanged` on a modern + * connection — the listen filter is the intersection of the configured + * sub-options and the server-advertised `listChanged` capabilities. + * `undefined` on a legacy connection, before connect, or when that + * intersection is empty (auto-open skipped). Exposed so the consumer can + * `close()` it. + */ + get autoOpenedSubscription(): McpSubscription | undefined { + return this._autoOpenedSubscription; + } + + /** + * Transport-level demux for `subscriptions/listen` notifications, before + * any decoding/era-gating/handler dispatch. Consumes the leading + * `notifications/subscriptions/acknowledged` referencing a live + * subscription id (resolves the ack waiter) and an inbound + * `notifications/cancelled` referencing a live string-typed subscription + * id (server-side teardown on stdio). Change notifications carrying a + * subscription id pass through to the existing registered handlers via + * `super`. An unmatched ack/cancelled is NOT consumed: it reaches + * `setNotificationHandler` / `fallbackNotificationHandler` instead of + * being silently swallowed. + */ + protected override _onnotification(raw: JSONRPCNotification, extra?: MessageExtraInfo): void { + if (raw.method === 'notifications/subscriptions/acknowledged') { + const params = raw.params as { _meta?: Record; notifications?: unknown } | undefined; + const subscriptionId = params?._meta?.[SUBSCRIPTION_ID_META_KEY]; + const entry = typeof subscriptionId === 'string' ? this._listenState.get(subscriptionId) : undefined; + if (entry !== undefined) { + const honored = SubscriptionFilterSchema.safeParse(params?.notifications ?? {}); + entry.settle({ ack: honored.success ? honored.data : {} }); + return; + } + } + if (raw.method === 'notifications/cancelled') { + const cancelledId = (raw.params as { requestId?: unknown } | undefined)?.requestId; + const entry = typeof cancelledId === 'string' ? this._listenState.get(cancelledId) : undefined; + if (entry !== undefined) { + // Handles BOTH the pre-ack and post-ack server-side cancel: + // while opening, settle rejects the pending listen() promise; + // once open, settle transitions to closed and `closed` resolves + // 'remote' so the consumer can observe the server-initiated + // close. + entry.settle({ cause: 'remote', error: new Error('subscriptions/listen: server cancelled the subscription') }); + return; + } + } + super._onnotification(raw, extra); + } + + /** + * Transport-level demux for `subscriptions/listen` responses. The spec + * defines listen as never receiving a JSON-RPC result; a JSON-RPC ERROR + * for the listen id is the server's pre-ack capacity/params rejection. A + * string-id response that matches a live `_listenState` entry is consumed + * here (Protocol's `_responseHandlers` map is keyed by NUMBER and never + * holds a listen id, so passing a string-id response through would + * surface as "unknown message ID" via `onerror`). + */ + protected override _onresponse(response: JSONRPCResponse): void { + const id = response.id; + const entry = typeof id === 'string' ? this._listenState.get(id) : undefined; + if (entry !== undefined) { + if (isJSONRPCErrorResponse(response)) { + entry.settle({ + cause: 'remote', + error: ProtocolError.fromError(response.error.code, response.error.message, response.error.data) + }); + } else { + entry.settle({ + cause: 'remote', + error: new SdkError( + SdkErrorCode.InvalidResult, + 'server answered subscriptions/listen with a result; expected the acknowledged notification' + ) + }); + } + return; + } + super._onresponse(response); + } + + /** + * Settle every live per-listen state machine on a transport-initiated + * close (the server dropping the connection on stdio/InMemory) before + * Protocol's `_onclose` tears the transport down. The base + * `_responseHandlers` settlement does not reach `_listenState` (listen + * ids are never registered there), so without this override a remote + * close would leave an in-flight `listen()` / open `McpSubscription` + * hanging. + */ + protected override _onclose(): void { + if (this._listenState.size > 0) { + const reason = new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed'); + for (const entry of this._listenState.values()) { + entry.settle({ cause: 'remote', error: reason }); + } + this._listenState.clear(); + } + super._onclose(); + } + /** * Calls a tool on the connected server and returns the result. Automatically validates structured output * if the tool has an `outputSchema`. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 5dea9a7cc5..277e84e0fd 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -50,6 +50,24 @@ export interface StartSSEOptions { * so that the response can be associated with the new resumed request. */ replayMessageId?: string | number; + + /** + * The per-request abort signal supplied by the caller via + * `TransportSendOptions.requestSignal`. When this signal is aborted the + * originating POST and its SSE response stream are torn down + * intentionally — `_handleSseStream` treats it exactly like the + * transport-level abort: no `onerror`, no reconnect. + */ + requestSignal?: AbortSignal; + + /** + * The per-request stream-end callback supplied via + * `TransportSendOptions.onRequestStreamEnd`. Fired when the SSE response + * stream for the originating POST ends or errors for any non-deliberate + * reason (server closed, network dropped, reconnection exhausted) — never + * when `requestSignal` was aborted. + */ + onRequestStreamEnd?: () => void; } /** @@ -164,6 +182,43 @@ export type StreamableHTTPClientTransportOptions = { protocolVersion?: string; }; +/** + * `AbortSignal.any` with a manual fallback. `AbortSignal.any` landed in + * Node 20.3; this package's `engines` floor is `>=20`, so 20.0–20.2 must be + * served by the fallback combinator (a controller that aborts on the first + * of `a` or `b`). The native path is preferred because it propagates the + * originating signal's `reason` and participates in GC the way the spec + * defines. + */ +function anySignal(a: AbortSignal, b: AbortSignal): AbortSignal { + if (typeof AbortSignal.any === 'function') { + return AbortSignal.any([a, b]); + } + const controller = new AbortController(); + if (a.aborted) return (controller.abort(a.reason), controller.signal); + if (b.aborted) return (controller.abort(b.reason), controller.signal); + // Standard polyfill shape: when EITHER input fires, remove the listener + // registered on the OTHER input too. `{once:true}` alone leaks the + // sibling listener — for `_send()`, `a` is the transport-lifetime signal, + // so every request-scoped `b` that aborts would otherwise leave one + // listener + closure pinned on `a` for the life of the transport. + const cleanup = (): void => { + a.removeEventListener('abort', onA); + b.removeEventListener('abort', onB); + }; + function onA(): void { + cleanup(); + controller.abort(a.reason); + } + function onB(): void { + cleanup(); + controller.abort(b.reason); + } + a.addEventListener('abort', onA, { once: true }); + b.addEventListener('abort', onB, { once: true }); + return controller.signal; +} + /** * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. * It will connect to a server using HTTP `POST` for sending messages and HTTP `GET` with Server-Sent Events @@ -254,7 +309,13 @@ export class StreamableHTTPClientTransport implements Transport { } private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { - const { resumptionToken } = options; + const { resumptionToken, requestSignal } = options; + // Same guard as `_handleSseStream`: a resurrected listen stream (the + // POST-SSE → GET reconnect path threads `requestSignal` through + // `StartSSEOptions`) must honour the per-request abort exactly as the + // original POST did — both as a fetch signal and as a "do not surface + // onerror" gate. + const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true; try { // Try to open an initial SSE stream with GET to listen for server messages @@ -269,11 +330,16 @@ export class StreamableHTTPClientTransport implements Transport { headers.set('last-event-id', resumptionToken); } + const transportSignal = this._abortController?.signal; + const signal = + requestSignal !== undefined && transportSignal !== undefined + ? anySignal(transportSignal, requestSignal) + : (requestSignal ?? transportSignal); const response = await (this._fetch ?? fetch)(this._url, { ...this._requestInit, method: 'GET', headers, - signal: this._abortController?.signal + signal }); if (!response.ok) { @@ -309,6 +375,15 @@ export class StreamableHTTPClientTransport implements Transport { // 405 indicates that the server does not offer an SSE stream at GET endpoint // This is an expected case that should not trigger an error if (response.status === 405) { + // A 405 on the standalone-GET path is benign (the caller + // never had a per-request stream). On the POST→GET resume + // path it is a TERMINAL non-resumable outcome for a + // per-request stream the caller is observing — fire the + // stream-end callback so the caller can settle (otherwise + // a resumed listen subscription dead-ends silently). The + // standalone-GET callers never pass `onRequestStreamEnd`, + // so this is a no-op for them. + options.onRequestStreamEnd?.(); return; } @@ -320,7 +395,9 @@ export class StreamableHTTPClientTransport implements Transport { this._handleSseStream(response.body, options, true); } catch (error) { - this.onerror?.(error as Error); + if (!isIntentionalAbort()) { + this.onerror?.(error as Error); + } throw error; } } @@ -359,6 +436,8 @@ export class StreamableHTTPClientTransport implements Transport { // Check if we've exceeded maximum retry attempts if (attemptCount >= maxRetries) { this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`)); + // The per-request stream is now definitively gone. + options.onRequestStreamEnd?.(); return; } @@ -367,8 +446,12 @@ export class StreamableHTTPClientTransport implements Transport { const reconnect = (): void => { this._cancelReconnection = undefined; - if (this._abortController?.signal.aborted) return; + // Honour BOTH the transport-wide abort and the per-request abort + // (a listen subscription closed during the backoff delay): do not + // resurrect a stream the caller already tore down. + if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return; this._startOrAuthSse(options).catch(error => { + if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return; this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); try { this._scheduleReconnection(options, attemptCount + 1); @@ -389,9 +472,20 @@ export class StreamableHTTPClientTransport implements Transport { private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions, isReconnectable: boolean): void { if (!stream) { + // A null body on a per-request stream (or its GET resume) is the + // same terminal non-resumable outcome as a 405 — fire the + // stream-end callback so the caller can settle. No-op for + // standalone-GET callers (they never pass `onRequestStreamEnd`). + options.onRequestStreamEnd?.(); return; } - const { onresumptiontoken, replayMessageId } = options; + const { onresumptiontoken, replayMessageId, requestSignal, onRequestStreamEnd } = options; + // An intentional abort — transport-wide close OR a per-request abort + // (McpSubscription.close() aborting its `requestSignal`) — must read as + // a clean shutdown: no misleading "SSE stream disconnected" onerror, + // and no GET+Last-Event-ID reconnect that would resurrect a stream the + // caller just tore down. + const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true; let lastEventId: string | undefined; // Track whether we've received a priming event (event with ID) @@ -460,17 +554,29 @@ export class StreamableHTTPClientTransport implements Transport { // BUT don't reconnect if we already received a response - the request is complete const canResume = isReconnectable || hasPrimingEvent; const needsReconnect = canResume && !receivedResponse; - if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { + if (needsReconnect && this._abortController && !isIntentionalAbort()) { this._scheduleReconnection( { resumptionToken: lastEventId, onresumptiontoken, - replayMessageId + replayMessageId, + requestSignal, + onRequestStreamEnd }, 0 ); + } else if (!isIntentionalAbort()) { + // The per-request stream ended without reconnecting (no + // priming event for a POST stream, or response already + // received). Not a deliberate abort — notify the caller. + onRequestStreamEnd?.(); } } catch (error) { + if (isIntentionalAbort()) { + // The reader threw because we aborted it. Not an error; do + // not surface onerror, do not reconnect. + return; + } // Handle stream errors - likely a network disconnect this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); @@ -479,20 +585,27 @@ export class StreamableHTTPClientTransport implements Transport { // BUT don't reconnect if we already received a response - the request is complete const canResume = isReconnectable || hasPrimingEvent; const needsReconnect = canResume && !receivedResponse; - if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { + if (needsReconnect && this._abortController && !isIntentionalAbort()) { // Use the exponential backoff reconnection strategy try { this._scheduleReconnection( { resumptionToken: lastEventId, onresumptiontoken, - replayMessageId + replayMessageId, + requestSignal, + onRequestStreamEnd }, 0 ); } catch (error) { this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); + onRequestStreamEnd?.(); } + } else { + // Non-deliberate stream error without reconnection: the + // per-request stream is gone — notify the caller. + onRequestStreamEnd?.(); } } }; @@ -541,14 +654,26 @@ export class StreamableHTTPClientTransport implements Transport { async send( message: JSONRPCMessage | JSONRPCMessage[], - options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } + options?: { + resumptionToken?: string; + onresumptiontoken?: (token: string) => void; + requestSignal?: AbortSignal; + onRequestStreamEnd?: () => void; + } ): Promise { return this._send(message, options, false); } private async _send( message: JSONRPCMessage | JSONRPCMessage[], - options: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } | undefined, + options: + | { + resumptionToken?: string; + onresumptiontoken?: (token: string) => void; + requestSignal?: AbortSignal; + onRequestStreamEnd?: () => void; + } + | undefined, isAuthRetry: boolean ): Promise { try { @@ -569,12 +694,21 @@ export class StreamableHTTPClientTransport implements Transport { const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream']; headers.set('accept', [...new Set(types)].join(', ')); + // Per-request abort: when the caller supplies a request-scoped + // signal (the `subscriptions/listen` driver), aborting it cancels + // this POST and its SSE response stream without closing the + // transport. + const transportSignal = this._abortController?.signal; + const signal = + options?.requestSignal !== undefined && transportSignal !== undefined + ? anySignal(transportSignal, options.requestSignal) + : (options?.requestSignal ?? transportSignal); const init = { ...this._requestInit, method: 'POST', headers, body: JSON.stringify(message), - signal: this._abortController?.signal + signal }; const response = await (this._fetch ?? fetch)(this._url, init); @@ -690,7 +824,15 @@ export class StreamableHTTPClientTransport implements Transport { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body, { onresumptiontoken }, false); + this._handleSseStream( + response.body, + { + onresumptiontoken, + requestSignal: options?.requestSignal, + onRequestStreamEnd: options?.onRequestStreamEnd + }, + false + ); } else if (contentType?.includes('application/json')) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); @@ -712,7 +854,15 @@ export class StreamableHTTPClientTransport implements Transport { await response.text?.().catch(() => {}); } } catch (error) { - this.onerror?.(error as Error); + // Intentional per-request abort BEFORE response headers (the + // `subscriptions/listen` driver aborting its `requestSignal`): + // fetch rejects with AbortError. Same guard as + // `_handleSseStream`'s `isIntentionalAbort` — do not surface a + // misleading onerror; still rethrow so `listen()`'s send-catch + // settles the per-subscription state machine. + if (options?.requestSignal?.aborted !== true) { + this.onerror?.(error as Error); + } throw error; } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 678bb4d45d..7fe7acb958 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -52,7 +52,7 @@ export { PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from './client/authExtensions.js'; -export type { ClientOptions } from './client/client.js'; +export type { ClientOptions, McpSubscription } from './client/client.js'; export { Client } from './client/client.js'; export { getSupportedElicitationModes } from './client/client.js'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess.js'; diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts new file mode 100644 index 0000000000..2476325c23 --- /dev/null +++ b/packages/client/test/client/listen.test.ts @@ -0,0 +1,936 @@ +/** + * `Client.listen()` — the `subscriptions/listen` driver (protocol revision + * 2026-07-28). Covers ack-resolved-promise, change-notification dispatch to + * existing setNotificationHandler registrations, the F-12 legacy-era steer, + * transport-agnostic close (always sends notifications/cancelled), inbound + * server-side cancel, and ClientOptions.listChanged auto-open on a modern + * connection. + */ +import type { JSONRPCMessage, JSONRPCNotification } from '@modelcontextprotocol/core'; +import { + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + SUBSCRIPTION_ID_META_KEY +} from '@modelcontextprotocol/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; +const flush = () => new Promise(r => setTimeout(r, 10)); + +async function scriptedModern(onListen?: (id: number | string, filter: unknown, send: (m: JSONRPCMessage) => void) => void) { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const req = message as { id?: number | string; method?: string; params?: { notifications?: unknown } }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } }, + serverInfo: { name: 'scripted', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + const filter = req.params?.notifications ?? {}; + const ack: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: req.id }, notifications: filter } + }; + void serverTx.send(ack); + onListen?.(req.id, filter, m => void serverTx.send(m)); + } + }; + await serverTx.start(); + return { clientTx, serverTx, written }; +} + +/** + * Like `scriptedModern` but does NOT auto-ack `subscriptions/listen`: the + * test drives ack / cancel / transport-close itself. + */ +async function scriptedModernNoAck() { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const req = message as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } }, + serverInfo: { name: 'scripted', version: '1' } + } + }); + } + }; + await serverTx.start(); + return { clientTx, serverTx, written }; +} + +describe('Client.listen()', () => { + it('throws a typed steer on a legacy-era connection (no wire write)', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = m => { + written.push(m); + const req = m as { id?: number | string; method?: string }; + if (req.method === 'initialize' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, serverInfo: { name: 's', version: '1' } } + }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'legacy' } }); + await client.connect(clientTx); + written.length = 0; + + const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).message).toContain('resources/subscribe'); + expect((error as SdkError).message).toContain('listChanged'); + // The steer fires before any wire write. + expect(written.some(m => (m as { method?: string }).method === 'subscriptions/listen')).toBe(false); + await client.close(); + }); + + it('resolves on ack with the honored filter; change notifications reach setNotificationHandler', async () => { + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((_id, _f, s) => { + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const seen: string[] = []; + client.setNotificationHandler('notifications/tools/list_changed', () => { + seen.push('tools'); + }); + await client.connect(clientTx); + + const sub = await client.listen({ toolsListChanged: true }); + expect(sub.honoredFilter).toEqual({ toolsListChanged: true }); + + send({ + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 0 } } + }); + await flush(); + expect(seen).toEqual(['tools']); + await sub.close(); + await client.close(); + }); + + it('close() sends notifications/cancelled referencing the listen id on any transport', async () => { + // Plain InMemoryTransport (neither child-process nor SSE-stream + // semantics): close() must NOT depend on transport-kind detection — + // it always sends notifications/cancelled, so a spec-compliant server + // on InMemory / SSE / a custom transport tears the subscription down. + const { clientTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + written.length = 0; + await sub.close(); + expect(written).toHaveLength(1); + const cancel = written[0] as unknown as { method: string; params: { requestId: unknown; _meta?: Record } }; + expect(cancel.method).toBe('notifications/cancelled'); + expect(cancel.params.requestId).toBe(listenId); + // The listen-path cancel carries the same modern auto-envelope as + // every other outbound (request()'s cancel, Protocol.notification()). + expect(cancel.params._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + // Idempotent. + await sub.close(); + expect(written).toHaveLength(1); + await client.close(); + }); + + it("inbound notifications/cancelled post-ack: closed resolves 'remote'; subscription torn down; handlers stop firing", async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const seen: string[] = []; + client.setNotificationHandler('notifications/tools/list_changed', () => { + seen.push('tools'); + }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + // The spec-defined remote termination signal is now observable on the + // subscription handle; settle() is the funnel and resolves it once. + await expect(sub.closed).resolves.toBe('remote'); + // Per-listen state is gone; the request signal was aborted (so an HTTP + // SSE reader would have stopped). + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // After a server-side close, the server stops delivering on this stream + // — a notification carrying this subscription id is no longer routed + // through any per-listen entry (the entry is gone). The handler is the + // shared setNotificationHandler registration; assert no later + // dispatch from THIS subscription's stream by asserting no entry exists + // to demux it. + expect((client as unknown as { _listenState: Map })._listenState.has(listenId)).toBe(false); + expect(seen).toEqual([]); + // close() after server-cancel is idempotent and does NOT change the + // already-resolved cause. + await sub.close(); + await expect(sub.closed).resolves.toBe('remote'); + await client.close(); + }); + + it("close() resolves closed with 'local' exactly once", async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + await sub.close(); + await expect(sub.closed).resolves.toBe('local'); + // A second close() and a later remote signal cannot change it. + await sub.close(); + await expect(sub.closed).resolves.toBe('local'); + await client.close(); + }); + + it('closed resolves exactly once even when multiple termination signals arrive', async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx, serverTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + const resolutions: string[] = []; + void sub.closed.then(cause => resolutions.push(cause)); + // Three signals in quick succession: server-cancel, a duplicate + // server-cancel, then transport close. settle()'s `closed` guard + // means only the first transitions; `closed` resolves once. + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + await serverTx.close(); + await flush(); + expect(resolutions).toEqual(['remote']); + // sub.close() after the fact is still idempotent and cannot flip it. + await sub.close(); + await expect(sub.closed).resolves.toBe('remote'); + }); + + it('rejects with the typed pre-ack error when the server answers -32603', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + void serverTx.send({ jsonrpc: '2.0', id: req.id, error: { code: -32_603, message: 'Subscription limit reached' } }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect((error as { code?: number }).code).toBe(-32_603); + await client.close(); + }); + + it('server cancels BEFORE the ack: listen() rejects immediately, no 60s hang', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + // Server cancels the listen id BEFORE sending the ack. + void serverTx.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: req.id } }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const t0 = Date.now(); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('server cancelled the subscription'); + // Rejected promptly (well under the 60s ack timeout). + expect(Date.now() - t0).toBeLessThan(1000); + // No leaked per-listen state for the listen id. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('an ack arriving AFTER the subscription was server-cancelled is a no-op', async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + // Server tears the open subscription down. + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + await flush(); + // A late duplicate ack must not throw or resurrect state. + send({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId }, notifications: {} } + }); + await flush(); + await sub.close(); + await client.close(); + }); + + it('a synchronously-delivered server-cancel during send does not leak a _listenState entry', async () => { + // In-process delivery: the server's notifications/cancelled arrives + // inside `transport.send()` (before the `await opening`). settle() + // must still drop the `_listenState` entry registered before send. + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + void serverTx.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: req.id } }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const listenState = (client as unknown as { _listenState: Map })._listenState; + const before = listenState.size; + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect((error as Error).message).toContain('server cancelled the subscription'); + // No leaked _listenState entry for the listen id. + expect(listenState.size).toBe(before); + await client.close(); + }); + + it('a synchronous transport.send throw does not leak a _listenState entry', async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const realSend = clientTx.send.bind(clientTx); + clientTx.send = () => { + throw new Error('send blew up'); + }; + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect((error as Error).message).toContain('send blew up'); + // settle() in the catch path dropped the _listenState entry that was + // registered before send threw; listen() never registers in + // Protocol's `_responseHandlers` so there is nothing to leak there. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + clientTx.send = realSend; + await client.close(); + }); + + it('options.signal aborted while opening: listen() rejects fast with the signal reason', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = m => { + written.push(m); + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + // No ack for subscriptions/listen — stays in `opening`. + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const ac = new AbortController(); + const t0 = Date.now(); + const pending = client.listen({ toolsListChanged: true }, { signal: ac.signal }); + ac.abort(new Error('caller-abort')); + const error = await pending.catch(e => e as Error); + expect((error as Error).message).toBe('caller-abort'); + expect(Date.now() - t0).toBeLessThan(1000); + // wireTeardown sent notifications/cancelled referencing the listen id. + await flush(); + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + const cancelled = written.find(m => (m as { method?: string }).method === 'notifications/cancelled') as + | { params: { requestId: unknown } } + | undefined; + expect(cancelled?.params.requestId).toBe(listenId); + // No leaked state. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('options.signal aborted while open: closes the subscription (notifications/cancelled sent)', async () => { + const { clientTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const ac = new AbortController(); + const sub = await client.listen({ toolsListChanged: true }, { signal: ac.signal }); + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + written.length = 0; + ac.abort(); + await flush(); + expect(written).toHaveLength(1); + expect((written[0] as JSONRPCNotification).method).toBe('notifications/cancelled'); + expect((written[0] as unknown as { params: { requestId: unknown } }).params.requestId).toBe(listenId); + // Caller-signal abort is consumer-initiated → 'local'. + await expect(sub.closed).resolves.toBe('local'); + // close() after signal-abort is idempotent. + await sub.close(); + expect(written).toHaveLength(1); + await client.close(); + }); + + it('rejects with NotConnected (as a rejected promise, no setup) when no transport is connected', async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + await client.close(); + // listen() is async, so a pre-send guard throw is delivered as the + // returned promise's rejection (no ack timer started, no park state). + const pending = client.listen({ toolsListChanged: true }); + const error = await pending.catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.NotConnected); + }); + + it('ClientOptions.listChanged auto-opens a listen stream on a modern connection (filter = configured ∩ server-advertised)', async () => { + const filters: unknown[] = []; + const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter)); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged }, prompts: { onChanged } } } + ); + await client.connect(clientTx); + expect(filters).toEqual([{ toolsListChanged: true, promptsListChanged: true }]); + expect(client.autoOpenedSubscription).toBeDefined(); + expect(client.autoOpenedSubscription!.honoredFilter).toEqual({ toolsListChanged: true, promptsListChanged: true }); + await client.autoOpenedSubscription!.close(); + await client.close(); + }); + + it('autoOpenedSubscription is cleared on close() and on a fresh reconnect', async () => { + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const { clientTx } = await scriptedModern(); + await client.connect(clientTx); + expect(client.autoOpenedSubscription).toBeDefined(); + await client.close(); + // close() clears every per-connection field. + expect(client.autoOpenedSubscription).toBeUndefined(); + expect(client.getServerCapabilities()).toBeUndefined(); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + }); + + it('auto-open filter is configured ∩ server-advertised; empty intersection skips auto-open', async () => { + const filters: unknown[] = []; + // scriptedModern advertises tools.listChanged + prompts.listChanged but NOT resources. + const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter)); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + // Configures tools + resources; server advertises tools + prompts. + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged }, resources: { onChanged } } } + ); + await client.connect(clientTx); + // Intersection = tools only. + expect(filters).toEqual([{ toolsListChanged: true }]); + expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true }); + await client.close(); + + // Empty intersection: configures resources only; server advertises tools+prompts. + const filters2: unknown[] = []; + const { clientTx: clientTx2 } = await scriptedModern((_id, filter) => filters2.push(filter)); + const client2 = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { resources: { onChanged } } } + ); + await client2.connect(clientTx2); + expect(filters2).toEqual([]); + expect(client2.autoOpenedSubscription).toBeUndefined(); + await client2.close(); + }); + + it('a failed auto-open surfaces via onerror and does NOT fail connect', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + // Server refuses listen (capacity guard / not supported). + void serverTx.send({ jsonrpc: '2.0', id: req.id, error: { code: -32_603, message: 'Subscription limit reached' } }); + } + }; + await serverTx.start(); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + // connect MUST resolve: the modern connection is usable without listen. + await client.connect(clientTx); + expect(client.autoOpenedSubscription).toBeUndefined(); + expect(errors).toHaveLength(1); + expect((errors[0] as { code?: number }).code).toBe(-32_603); + await client.close(); + }); + + it('a misconfigured listChanged handler surfaces via onerror and SKIPS auto-open (no wire write)', async () => { + // Regression: when handler registration threw (the soft-fail catch), + // the auto-open filter was still built from the same `effective`, + // opening a listen stream for types whose handler never registered — + // delivered notifications dropped on the floor while consuming a + // server slot. Now a registration failure skips auto-open entirely. + const { clientTx, written } = await scriptedModernNoAck(); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged, debounceMs: -1 } } } + ); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + // connect MUST resolve: the modern connection is usable without listen. + await client.connect(clientTx); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toContain('Invalid tools listChanged options'); + // Auto-open SKIPPED: no listen request hit the wire, no subscription. + expect(client.autoOpenedSubscription).toBeUndefined(); + expect(written.some(m => (m as { method?: string }).method === 'subscriptions/listen')).toBe(false); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('connect-scoped signal does NOT bind to the auto-opened subscription lifetime', async () => { + // Regression: forwarding connect()'s full RequestOptions into the + // auto-open listen() call meant a connect-scoped signal — typically + // `AbortSignal.timeout(30_000)` for the handshake — was bound to the + // SUBSCRIPTION lifetime. When it fired after connect resolved, the + // auto-opened stream was silently torn down. + const { clientTx, written } = await scriptedModern(); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + const connectScoped = new AbortController(); + await client.connect(clientTx, { signal: connectScoped.signal }); + expect(client.autoOpenedSubscription).toBeDefined(); + written.length = 0; + + // The connect-scoped signal fires AFTER connect resolved (as a + // handshake `AbortSignal.timeout` would). + connectScoped.abort(); + await flush(); + + // The auto-opened subscription is still live: no wire teardown + // (`notifications/cancelled`) was sent, and the per-listen state + // entry is still registered. + expect(written.some(m => (m as JSONRPCNotification).method === 'notifications/cancelled')).toBe(false); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); + expect(errors).toHaveLength(0); + await client.close(); + }); + + it('connect-scoped signal aborted DURING the auto-open ack wait: connect rejects fast (no 60s hang)', async () => { + // Regression: forwarding only {timeout} into the auto-open listen() + // meant connect()'s signal could not cancel the in-connect ack wait — + // an aborted connect blocked here for the full ack timeout. + const { clientTx } = await scriptedModernNoAck(); + const closeSpy = vi.spyOn(clientTx, 'close'); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const connectScoped = new AbortController(); + const t0 = Date.now(); + const pending = client.connect(clientTx, { signal: connectScoped.signal }); + // discover resolves; connect is now awaiting the auto-open ack. + await flush(); + connectScoped.abort(new Error('connect-abort')); + const error = await pending.catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect(Date.now() - t0).toBeLessThan(1000); + // No leaked per-listen state on the aborted connect. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // A connect() rejection MUST NOT leave a half-open connection: the + // transport was closed before rethrowing (b142b80ea regression assertion). + await flush(); + expect(closeSpy).toHaveBeenCalled(); + expect(client.transport).toBeUndefined(); + await client.close(); + }); + + it('server answers listen with a JSON-RPC RESULT during opening: rejects with a typed InvalidResult (not 60s)', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + // Buggy server: answers with a result instead of the + // acknowledged notification. Spec defines listen as never + // receiving a result. + void serverTx.send({ jsonrpc: '2.0', id: req.id, result: {} }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const t0 = Date.now(); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.InvalidResult); + expect((error as SdkError).message).toContain('expected the acknowledged notification'); + expect(Date.now() - t0).toBeLessThan(1000); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('transport closes BEFORE the ack: listen() rejects fast', async () => { + const { clientTx, serverTx } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const t0 = Date.now(); + const pending = client.listen({ toolsListChanged: true }); + await flush(); + // Server-side transport closes before ever acking → Client's + // `_onclose` override settles every per-listen state machine. + await serverTx.close(); + const error = await pending.catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect(Date.now() - t0).toBeLessThan(1000); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + }); + + it("transport closes WHILE the subscription is open: closed resolves 'remote'; close() is a no-op", async () => { + const { clientTx, serverTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); + await serverTx.close(); + await expect(sub.closed).resolves.toBe('remote'); + // Transport-close settled the per-listen machine; nothing leaks. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // sub.close() after transport-close is a no-op (state already 'closed'): + // no notifications/cancelled lands on a future connection. + written.length = 0; + await sub.close(); + expect(written.some(m => (m as { method?: string }).method === 'notifications/cancelled')).toBe(false); + }); + + it('concurrent listens are independent (each ack resolves its own promise; closing one leaves the other open)', async () => { + const ids: (number | string)[] = []; + const { clientTx, written } = await scriptedModern(id => ids.push(id)); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const [a, b] = await Promise.all([client.listen({ toolsListChanged: true }), client.listen({ promptsListChanged: true })]); + expect(a.honoredFilter).toEqual({ toolsListChanged: true }); + expect(b.honoredFilter).toEqual({ promptsListChanged: true }); + expect(ids).toHaveLength(2); + expect(ids[0]).not.toBe(ids[1]); + const listenState = (client as unknown as { _listenState: Map })._listenState; + expect(listenState.size).toBe(2); + written.length = 0; + await a.close(); + // Only `a`'s id is cancelled; `b` stays open. + expect(written).toHaveLength(1); + expect((written[0] as JSONRPCNotification).method).toBe('notifications/cancelled'); + expect((written[0] as unknown as { params: { requestId: unknown } }).params.requestId).toBe(ids[0]); + expect(listenState.size).toBe(1); + await b.close(); + expect(listenState.size).toBe(0); + await client.close(); + }); + + it('after close(): nothing further dispatched into the per-listen machine; late ack passes through unconsumed', async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + await sub.close(); + // The per-listen entry is gone; a late server-side ack and a late + // server-side cancel for this id are NOT consumed by the + // `_onnotification` override (no entry matches) and reach the + // fallback handler. + const fallback: string[] = []; + client.fallbackNotificationHandler = async n => { + fallback.push(n.method); + }; + send({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId }, notifications: {} } + }); + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + await flush(); + expect(fallback).toContain('notifications/subscriptions/acknowledged'); + // The state machine stayed closed throughout (no leak, no resurrection). + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('an unmatched ack passes through to fallbackNotificationHandler (not silently swallowed)', async () => { + const { clientTx, serverTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const fallback: string[] = []; + client.fallbackNotificationHandler = async n => { + fallback.push(n.method); + }; + await client.connect(clientTx); + // One listen is active; a stray ack referencing a FOREIGN id must + // reach the fallback handler instead of being silently swallowed. + const sub = await client.listen({ toolsListChanged: true }); + await serverTx.send({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 'foreign-id' }, notifications: {} } + }); + await flush(); + expect(fallback).toEqual(['notifications/subscriptions/acknowledged']); + await sub.close(); + await client.close(); + }); + + it('a fresh connect without an intervening close settles in-flight listen() from the prior connection', async () => { + // Edge: prior transport never fires onclose; consumer calls connect() + // again. The in-flight listen() promise from the old connection must + // reject with a clear "client reconnected/closed" error rather than + // hang on the (now-discarded) ack timer. + const { clientTx } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const pending = client.listen({ toolsListChanged: true }); + await flush(); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); + // Fresh connect on a new transport — _resetConnectionState runs. + const { clientTx: clientTx2 } = await scriptedModern(); + await client.connect(clientTx2); + const error = await pending.catch(e => e as Error); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed); + expect((error as SdkError).message).toContain('reconnected or closed'); + // No leaked per-listen state from the old connection. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it("the listen request id is a STRING on the wire ('listen:N'); cancel echoes it verbatim", async () => { + const { clientTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + const wireListen = written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { + id: unknown; + params: { _meta?: Record }; + }; + // String id from a Client-owned counter — JSON-RPC valid; spec + // subscriptionId is the request id verbatim; zero collision with + // Protocol's numeric counter. + expect(typeof wireListen.id).toBe('string'); + expect(wireListen.id).toMatch(/^listen:\d+$/); + // The auto-envelope is on the wire too. + expect(wireListen.params._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + written.length = 0; + await sub.close(); + const cancel = written[0] as unknown as { method: string; params: { requestId: unknown } }; + expect(cancel.params.requestId).toBe(wireListen.id); + await client.close(); + }); + + it("transport-level per-request stream end (onRequestStreamEnd) → closed resolves 'remote'", async () => { + // Mock a transport that captures the per-request `onRequestStreamEnd` + // callback and fires it after the ack — simulating a Streamable HTTP + // server closing the listen request's SSE stream. + const { clientTx, serverTx } = await scriptedModern(); + let onStreamEnd: (() => void) | undefined; + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m, opts) => { + if ((m as { method?: string }).method === 'subscriptions/listen') { + onStreamEnd = (opts as { onRequestStreamEnd?: () => void } | undefined)?.onRequestStreamEnd; + } + return realSend(m, opts); + }; + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + expect(onStreamEnd).toBeDefined(); + // Transport reports the per-request stream ended (server closed the + // SSE response, network dropped it, reconnection exhausted). + onStreamEnd!(); + await expect(sub.closed).resolves.toBe('remote'); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // close() after stream-end is a no-op (state already 'closed'). + await sub.close(); + await serverTx.close(); + }); + + it('close() resets per-connection state even when transport.close() rejects', async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + clientTx.close = () => Promise.reject(new Error('close blew up')); + await expect(client.close()).rejects.toThrow('close blew up'); + // Per-connection state was cleared regardless. + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + }); +}); + +describe('_resetConnectionState() clears connection-scoped debounce timers (fake timers)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('a debounced listChanged callback armed on a closed connection never fires', async () => { + const { clientTx, serverTx } = await scriptedModernNoAck(); + const calls: unknown[] = []; + const client = new Client( + { name: 'c', version: '1' }, + { + versionNegotiation: { mode: 'auto' }, + listChanged: { tools: { onChanged: (e, items) => calls.push({ e, items }), autoRefresh: false, debounceMs: 100 } } + } + ); + const connecting = client.connect(clientTx); + await vi.runAllTimersAsync(); + await connecting; + // Arm the debounce timer for `tools` on the current connection. + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }); + await vi.advanceTimersByTimeAsync(0); + expect((client as unknown as { _listChangedDebounceTimers: Map })._listChangedDebounceTimers.size).toBe(1); + // close() → _resetConnectionState() must clear the armed timer so the + // callback for the dead connection never fires. + await client.close(); + expect((client as unknown as { _listChangedDebounceTimers: Map })._listChangedDebounceTimers.size).toBe(0); + await vi.advanceTimersByTimeAsync(200); + expect(calls).toEqual([]); + }); +}); + +describe('Client.listen() — ack timeout (fake timers)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('ack timer firing rejects with RequestTimeout and tears the wire down', async () => { + const { clientTx, written } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const connecting = client.connect(clientTx); + await vi.runAllTimersAsync(); + await connecting; + const pending = client.listen({ toolsListChanged: true }, { timeout: 1000 }); + // Capture rejection to avoid an unhandled-rejection on the timer tick. + const settled = pending.catch(e => e as SdkError); + await vi.advanceTimersByTimeAsync(1000); + const error = await settled; + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + // wireTeardown sent notifications/cancelled referencing the listen id. + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + const cancelled = written.find(m => (m as JSONRPCNotification).method === 'notifications/cancelled'); + expect(cancelled).toMatchObject({ params: { requestId: listenId } }); + // No leaked state. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + // Restore real timers before close to avoid hanging on transport timers. + vi.useRealTimers(); + await client.close(); + }); +}); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 6542302c9d..04d4615b41 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1102,6 +1102,407 @@ describe('StreamableHTTPClientTransport', () => { expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); }); + it('per-request requestSignal abort: no onerror, no reconnect (McpSubscription.close())', async () => { + // ARRANGE — a POST stream that has been primed with an SSE event id + // (server-side resumability), so without the per-request abort + // guard the transport WOULD schedule a GET+Last-Event-ID reconnect. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + let streamController!: ReadableStreamDefaultController; + const primedStream = new ReadableStream({ + start(controller) { + streamController = controller; + // Priming event with an id — would arm POST-stream resumability. + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + // Propagate abort to the stream the way fetch does. + init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true }); + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: primedStream + }); + }); + + const requestAbort = new AbortController(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen-1', params: {} }, + { requestSignal: requestAbort.signal } + ); + await vi.advanceTimersByTimeAsync(5); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // ACT — McpSubscription.close() aborts the per-request signal. + requestAbort.abort(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — intentional per-request abort: no onerror, no reconnect. + expect(errorSpy).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('onRequestStreamEnd fires when the per-request POST stream ends gracefully without reconnecting', async () => { + // ARRANGE — a POST stream with NO priming event id (so the + // graceful-close path does NOT schedule a reconnect): the + // per-request stream simply ends. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + let streamController!: ReadableStreamDefaultController; + const unprimedStream = new ReadableStream({ + start(controller) { + streamController = controller; + // An ack frame with no SSE event id — does NOT arm POST-stream resumability. + controller.enqueue( + new TextEncoder().encode( + 'data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{}}\n\n' + ) + ); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: unprimedStream + }) + ); + + const requestAbort = new AbortController(); + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + expect(onStreamEnd).not.toHaveBeenCalled(); + + // ACT — server gracefully closes the SSE stream. + streamController.close(); + await vi.advanceTimersByTimeAsync(5); + + // ASSERT — non-deliberate stream end without reconnecting: + // onRequestStreamEnd fired exactly once; no further fetches. + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('onRequestStreamEnd does NOT fire on a deliberate per-request abort', async () => { + // Same shape as the no-onerror/no-reconnect test, but assert the + // stream-end callback is NEVER invoked when `requestSignal` was the + // abort source. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true }); + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: stream + }); + }); + + const requestAbort = new AbortController(); + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + + // ACT — deliberate per-request abort. + requestAbort.abort(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — deliberate abort: onRequestStreamEnd never fires. + expect(onStreamEnd).not.toHaveBeenCalled(); + }); + + it('onRequestStreamEnd fires when reconnection attempts are exhausted (maxRetries reached)', async () => { + // ARRANGE — a primed POST stream (so a non-deliberate close + // schedules a GET resume); every GET resume fails; maxRetries 1 + // means the second schedule hits the exhausted branch. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 5, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + let streamController!: ReadableStreamDefaultController; + const primedStream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: primedStream + }); + // The GET resume fails with a 5xx → reconnect catch reschedules → exhausted. + fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'unavailable', headers: new Headers() }); + + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + expect(onStreamEnd).not.toHaveBeenCalled(); + + // ACT — server closes the primed POST stream non-deliberately. + streamController.close(); + await vi.advanceTimersByTimeAsync(100); + + // ASSERT — exhausted: onRequestStreamEnd fired exactly once (the + // max-retries branch); the exhausted onerror surfaced. + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Maximum reconnection attempts') }) + ); + }); + + it('onRequestStreamEnd fires when the per-request POST stream ERRORS without reconnecting', async () => { + // ARRANGE — a POST stream with NO priming event id; the body + // errors (network drop). The error-branch `else` (no reconnect, + // not intentional-abort) must fire onRequestStreamEnd. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + const failingStream = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{}}\n\n' + ) + ); + queueMicrotask(() => controller.error(new Error('network drop'))); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: failingStream + }); + + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — error-branch fired exactly once; no reconnection + // attempted (POST stream wasn't primed). + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('onRequestStreamEnd does NOT fire on transport.close()', async () => { + // The transport-wide abort is the OTHER deliberate teardown + // (`isIntentionalAbort()` checks both signals): a per-request + // stream-end callback must not fire when close() tore the stream + // down — `_onclose` is the settle path for that. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true }); + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: stream + }); + }); + + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + + // ACT — transport-wide close. + await transport.close(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — deliberate transport close: onRequestStreamEnd never fires. + expect(onStreamEnd).not.toHaveBeenCalled(); + }); + + it('onRequestStreamEnd fires when a primed POST→GET resume hits 405 (non-resumable terminal)', async () => { + // R1 regression: against a server that stamps SSE event ids on the + // listen POST stream but returns 405 on the GET resume, + // `_startOrAuthSse` resolved without a stream and nothing fired — + // the subscription dead-ended silently. The 405 is now a terminal + // per-request stream-end. ALSO asserts the GET resume carried the + // per-request `requestSignal` (the close-after-reconnect path). + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 5, + maxRetries: 3, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + let streamController!: ReadableStreamDefaultController; + const primedStream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + let getSignal: AbortSignal | null | undefined; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: primedStream + }); + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + getSignal = init.signal; + return Promise.resolve({ ok: false, status: 405, headers: new Headers() }); + }); + + const requestAbort = new AbortController(); + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + + // ACT — server closes the primed POST stream → schedules a GET resume → 405. + streamController.close(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — onRequestStreamEnd fired exactly once on the 405; the + // resume was a single GET (no further retries — 405 resolves). + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]![1]?.method).toBe('GET'); + // requestSignal threaded through the GET reconnect: aborting the + // per-request signal aborts the resume's fetch signal. + expect(getSignal).toBeDefined(); + expect(getSignal?.aborted).toBe(false); + requestAbort.abort(); + expect(getSignal?.aborted).toBe(true); + }); + + it('per-request requestSignal abort BEFORE response headers: no misleading onerror; send() still rejects', async () => { + // ARRANGE — fetch is in flight (pending promise) when the + // requestSignal aborts; fetch rejects with AbortError before the + // SSE stream handler ever runs. _send's catch must apply the same + // intentional-abort guard as _handleSseStream. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce( + (_url, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }) + ); + + const requestAbort = new AbortController(); + await transport.start(); + const sent = transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen-1', params: {} }, + { requestSignal: requestAbort.signal } + ); + // Let _send reach the in-flight fetch. + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // ACT — abort before headers. + requestAbort.abort(new Error('intentional')); + + // ASSERT — send() rejects (so listen()'s send-catch settles), but no onerror. + await expect(sent).rejects.toThrow(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('anySignal fallback removes the sibling listener (no leak on the transport-lifetime signal)', async () => { + // ARRANGE — force the manual fallback path (Node 20.0–20.2). + const nativeAny = AbortSignal.any; + (AbortSignal as { any?: unknown }).any = undefined; + try { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValue({ ok: true, status: 202, headers: new Headers() }); + await transport.start(); + + const transportSignal = (transport as unknown as { _abortController: AbortController })._abortController.signal; + const addSpy = vi.spyOn(transportSignal, 'addEventListener'); + const removeSpy = vi.spyOn(transportSignal, 'removeEventListener'); + + // ACT — N sends each with a fresh request-scoped signal that + // aborts after the send completes (the McpSubscription.close() + // pattern). Each send registers one fallback listener on the + // transport-lifetime signal; aborting the request-scoped + // signal must remove it. + for (let i = 0; i < 5; i++) { + const requestAbort = new AbortController(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: `listen-${i}`, params: {} }, + { requestSignal: requestAbort.signal } + ); + requestAbort.abort(); + } + + // ASSERT — every listener registered on the transport-lifetime + // signal was removed; nothing accrues per send(). + expect(addSpy.mock.calls.length).toBeGreaterThan(0); + expect(removeSpy.mock.calls.length).toBe(addSpy.mock.calls.length); + } finally { + (AbortSignal as { any?: unknown }).any = nativeAny; + } + }); + it('should NOT reconnect a POST stream when error response was received', async () => { // ARRANGE transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 873aa6f92a..eaf14d5c67 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -735,7 +735,12 @@ export abstract class Protocol { await this._transport.start(); } - private _onclose(): void { + /** + * Transport-close hook. Subclass overrides MUST call `super._onclose()` + * after their own cleanup — base teardown (response-handler settlement, + * timeout clearing, in-flight request abort) does not run otherwise. + */ + protected _onclose(): void { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); @@ -770,7 +775,13 @@ export abstract class Protocol { this.onerror?.(error); } - private _onnotification(rawNotification: JSONRPCNotification, extra?: MessageExtraInfo): void { + /** + * Inbound-notification dispatch. Subclass overrides MUST delegate + * unmatched traffic to `super._onnotification(rawNotification, extra)` — + * an override that consumes only what it owns and falls through to base + * dispatch for everything else. + */ + protected _onnotification(rawNotification: JSONRPCNotification, extra?: MessageExtraInfo): void { // Hide wire-only material from notification handlers too — but ONLY // the reserved envelope `_meta` keys (the retry params names are // reserved on requests, not notifications). There is no @@ -1086,7 +1097,13 @@ export abstract class Protocol { handler(params); } - private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { + /** + * Inbound-response dispatch. Subclass overrides MUST delegate unmatched + * traffic to `super._onresponse(response)` — an override that consumes + * only what it owns and falls through to base dispatch for everything + * else. + */ + protected _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { const messageId = Number(response.id); const handler = this._responseHandlers.get(messageId); diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index c606e2e3b5..c9be6ee56c 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -67,6 +67,26 @@ export type TransportSendOptions = { * This allows clients to persist the latest token for potential reconnection. */ onresumptiontoken?: ((token: string) => void) | undefined; + + /** + * An abort signal for THIS outbound message's underlying request, when the + * transport sends one outbound message per underlying request (the + * Streamable HTTP transport's POST-per-request model). Aborting it cancels + * the underlying request (and its SSE response stream) without closing the + * transport. Transports that share a single channel (stdio, in-memory) + * ignore it. + */ + requestSignal?: AbortSignal | undefined; + + /** + * Fired by transports that open a per-request stream (the Streamable HTTP + * transport's POST-per-request SSE response) when that stream ends or + * errors for any reason OTHER than a deliberate `requestSignal` abort — + * i.e. the server closed the stream, the network dropped it, or + * reconnection was exhausted. Transports that share a single channel + * (stdio, in-memory) ignore it. + */ + onRequestStreamEnd?: (() => void) | undefined; }; /** * Describes the minimal contract for an MCP transport that a client or server can communicate over. diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 3d4bd9940e..4f072fd748 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -566,7 +566,7 @@ export type ResultTypeMap = { // `subscriptions/listen` never receives a JSON-RPC result on the wire: // termination is stream close (HTTP) or `notifications/cancelled` (stdio). // The `EmptyResult` entry exists only to keep the mapped types total — - // see the serving entries' listen routers. + // see `Client.listen()` and the serving entries' listen routers. 'subscriptions/listen': EmptyResult; 'tools/call': CallToolResult; 'tools/list': ListToolsResult; diff --git a/packages/core/src/wire/rev2026-07-28/registry.ts b/packages/core/src/wire/rev2026-07-28/registry.ts index 49df49429f..969c719bbd 100644 --- a/packages/core/src/wire/rev2026-07-28/registry.ts +++ b/packages/core/src/wire/rev2026-07-28/registry.ts @@ -23,7 +23,9 @@ * (SEP-1865): 2026-only vocabulary, present here as registry shells. * Dispatch never reaches a registered handler — the serving entries * (`createMcpHandler`, `serveStdio`) recognize listen at the entry layer - * and own ack/filter/stamp/teardown themselves. + * and own ack/filter/stamp/teardown themselves; on the client side + * `Client.listen()` sends directly on the transport (string-typed + * request id, transport-level demux) rather than via `request()`. */ import type * as z from 'zod/v4'; diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md index d44ba8a03a..586f390698 100644 --- a/test/e2e/CLAUDE.md +++ b/test/e2e/CLAUDE.md @@ -73,7 +73,7 @@ entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' ``` Omitting `arm` excludes both arms. The reasons (`EntryExclusionReason` in types.ts) are the acceptance checklist for re-admitting cells when the corresponding entry feature lands; a coverage gate rejects annotations that would never have an effect. Requirement families that the -per-request entry structurally cannot serve at all (server→client requests, sessions/resumability, standalone GET streams, subscriptions) are already expressed through their `transports` restrictions and need no annotation. +per-request entry structurally cannot serve at all (server→client requests, sessions/resumability, standalone GET streams) are already expressed through their `transports` restrictions and need no annotation. Arm-specific helpers: `wire()`'s fourth argument also accepts `entry` (createMcpHandler hosting overrides — e.g. a `responseMode` or a different `legacy` posture), the returned `Wired.httpLog` records every HTTP exchange (request body, status, content-type, a readable response clone) for raw wire assertions, factories may accept the optional per-request context (`EntryServerFactory`), and `modernEnvelopeMeta()` builds the envelope for bodies that POST raw 2026-era requests through `wired.fetch`. Compositions that the entry no longer expresses through diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 48c715ab66..5058dcdd5d 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2667,6 +2667,53 @@ export const REQUIREMENTS: Record = { 'The SDK provides a server-side legacy HTTP+SSE transport so existing SSE deployments can be hosted on SDK components alone.', transports: ['sse'], note: 'This asserts the availability of the server half of the legacy SSE transport (SSEServerTransport from @modelcontextprotocol/server-legacy/sse); the matrix transport arg is ignored, so it runs as a single sse-labelled cell.' + }, + 'subscriptions:listen:ack-first-stamped': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions#acknowledgment', + behavior: + "notifications/subscriptions/acknowledged is the first message on a subscriptions/listen stream and carries the listen request's JSON-RPC id verbatim under the io.modelcontextprotocol/subscriptionId _meta key, plus the honored subset of the requested filter.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' + }, + 'subscriptions:listen:per-stream-filter': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions#notification-filter', + behavior: + 'A subscriptions/listen stream receives only the notification types its filter explicitly requested; an un-requested type is provably never delivered. Change notifications dispatch to the existing setNotificationHandler registrations.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' + }, + 'subscriptions:listen:honored-filter-narrows-to-advertised': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions#acknowledgment', + behavior: + "The acknowledged filter on a subscriptions/listen stream is the requested set narrowed against the server's declared listChanged/subscribe capability bits — a requested type the server does not advertise is dropped from honoredFilter and is never delivered.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify. A stdio e2e of the modern listen path is not yet feasible without harness changes (the e2e stdio arms wire the standard child-process StdioServerTransport, not the serveStdio entry); stdio narrowing is covered at unit level in serveStdioListen.test.ts.' + }, + 'subscriptions:listen:capacity-guard': { + source: 'sdk', + behavior: + "A subscriptions/listen request is refused with -32603 'Subscription limit reached' (in-band on HTTP 200, before the ack) when the configured maxSubscriptions is reached.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler with maxSubscriptions: 1.' + }, + 'typescript:subscriptions:listChanged-auto-open-modern': { + source: 'sdk', + behavior: + 'ClientOptions.listChanged auto-opens a subscriptions/listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised listChanged capabilities (auto-open is skipped and autoOpenedSubscription stays undefined when the intersection is empty) — so the configured handlers fire on every published change. The auto-opened subscription is exposed for close.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' + }, + 'typescript:subscriptions:listen:legacy-era-steer': { + source: 'sdk', + behavior: + 'On a 2025-era connection, Client.listen() throws a typed MethodNotSupportedByProtocolVersion error steering to resources/subscribe and ClientOptions.listChanged before any wire write (no transparent shim).', + removedInSpecVersion: '2026-07-28', + note: 'Runs on the 2025-era arms; the entryModern arm is bound out by the removedInSpecVersion.' } } satisfies Record; diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts new file mode 100644 index 0000000000..bbca6105d5 --- /dev/null +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -0,0 +1,161 @@ +/** + * `subscriptions/listen` (SEP-1865, protocol revision 2026-07-28) through the + * public surface: ack-first, subscription-id stamping, per-stream filtering, + * the listChanged auto-open bridge, and the F-12 legacy steer. + * + * The 2026-era cells host `createMcpHandler` themselves (the test publishes + * via `handler.notify.*`); the legacy cell runs on the standard arms. + */ +import { Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler, McpServer, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { modernEnvelopeMeta, wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +function makeServer() { + const server = new McpServer({ name: 'subs-e2e', version: '1' }); + server.registerTool('greet', { inputSchema: z.object({}) }, async () => ({ content: [] })); + return server; +} + +async function hostListen() { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 }); + const url = new URL('http://in-process/mcp'); + const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init)); + const client = new Client({ name: 'subs-e2e-client', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(url, { fetch })); + expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28'); + return { + client, + handler, + fetch, + url, + [Symbol.asyncDispose]: () => Promise.all([client.close(), handler.close()]).then(() => {}) + }; +} + +verifies('subscriptions:listen:ack-first-stamped', async () => { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 }); + const response = await handler.fetch( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'sub-1', + method: 'subscriptions/listen', + params: { _meta: modernEnvelopeMeta(), notifications: { toolsListChanged: true } } + }) + }) + ); + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + const reader = response.body!.getReader(); + const { value } = await reader.read(); + const frame = new TextDecoder().decode(value); + const ack = JSON.parse(frame.slice(frame.indexOf('data: ') + 6, frame.indexOf('\n\n'))) as { + method: string; + params: { _meta: Record; notifications: unknown }; + }; + expect(ack.method).toBe('notifications/subscriptions/acknowledged'); + expect(ack.params._meta[SUBSCRIPTION_ID_META_KEY]).toBe('sub-1'); + expect(ack.params.notifications).toEqual({ toolsListChanged: true }); + await reader.cancel(); + await handler.close(); +}); + +verifies('subscriptions:listen:per-stream-filter', async () => { + await using h = await hostListen(); + const seen: string[] = []; + h.client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools')); + h.client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts')); + const sub = await h.client.listen({ toolsListChanged: true }); + h.handler.notify.promptsChanged(); + h.handler.notify.toolsChanged(); + await new Promise(r => setTimeout(r, 30)); + // The un-requested type was provably never delivered. + expect(seen).toEqual(['tools']); + await sub.close(); +}); + +verifies('typescript:subscriptions:listChanged-auto-open-modern', async () => { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 }); + const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init)); + let count = 0; + let done!: () => void; + const finished = new Promise(r => { + done = r; + }); + const client = new Client( + { name: 'subs-e2e-client', version: '1' }, + { + versionNegotiation: { mode: 'auto' }, + listChanged: { tools: { autoRefresh: false, onChanged: () => (++count >= 1 ? done() : undefined) } } + } + ); + await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch })); + expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true }); + handler.notify.toolsChanged(); + await finished; + expect(count).toBe(1); + await client.autoOpenedSubscription!.close(); + await client.close(); + await handler.close(); +}); + +verifies('typescript:subscriptions:listen:legacy-era-steer', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'c', version: '0' }); + await using _ = await wire(transport, makeServer, client); + const error = await client.listen({ toolsListChanged: true }).catch(error_ => error_ as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).message).toContain('resources/subscribe'); +}); + +verifies('subscriptions:listen:honored-filter-narrows-to-advertised', async () => { + // makeServer registers a tool but no prompts/resources: a listen requesting + // toolsListChanged + promptsListChanged + resourcesListChanged must come + // back honored as toolsListChanged only — the ack reflects only what the + // server advertises. + await using h = await hostListen(); + const sub = await h.client.listen({ toolsListChanged: true, promptsListChanged: true, resourcesListChanged: true }); + expect(sub.honoredFilter).toEqual({ toolsListChanged: true }); + // And nothing the server doesn't advertise reaches the stream: the entry + // delivers via the same narrowed filter it acknowledged. + const seen: string[] = []; + h.client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts')); + h.client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools')); + h.handler.notify.promptsChanged(); + h.handler.notify.toolsChanged(); + await new Promise(r => setTimeout(r, 30)); + expect(seen).toEqual(['tools']); + await sub.close(); +}); + +verifies('subscriptions:listen:capacity-guard', async () => { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0, maxSubscriptions: 1 }); + const post = (id: number) => + handler.fetch( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'subscriptions/listen', + params: { _meta: modernEnvelopeMeta(), notifications: {} } + }) + }) + ); + const first = await post(1); + expect(first.headers.get('Content-Type')).toBe('text/event-stream'); + const second = await post(2); + expect(second.headers.get('Content-Type')).toContain('application/json'); + const body = (await second.json()) as { error: { code: number; message: string } }; + expect(body.error.code).toBe(-32_603); + expect(body.error.message).toBe('Subscription limit reached'); + await first.body!.cancel(); + await handler.close(); +}); From 8766f689e98c5f7fdba0538da1906053168ffe55 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:02:54 +0100 Subject: [PATCH 31/37] test(conformance): arm the fixture for the sep-2575 list_changed-on-listen SHOULD checks (#2323) --- .../expected-failures.2026-07-28.yaml | 4 -- test/conformance/expected-failures.yaml | 8 ---- test/conformance/src/everythingServer.ts | 37 +++++++++++++++++++ 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index e23ffbe1b3..5c30ea0ade 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -67,10 +67,6 @@ client: server: # --- Carried-forward scenarios (also run by the 2025 legs) --- - # WARNING-only: see the matching entry in expected-failures.yaml — the - # sep-2575 list_changed-on-listen SHOULD checks; fixture armed in a - # follow-up change. - - server-stateless # Pre-existing fixture/baseline bug: the fixture tool's schema is a plain # Zod object with none of the JSON Schema 2020-12 keywords the scenario # checks; it fails identically at 2025 in `--suite all` (not a 2026-path diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 3b76bd94bf..c5ab22325a 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -49,14 +49,6 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) --- - # WARNING-only: with the listChanged capability now advertised in - # server/discover, server-stateless runs three additional SHOULD-level - # checks (sep-2575-server-sends-{tools,prompts,resources}-list-changed-on- - # subscription) that the conformance fixture cannot yet satisfy because it - # is not wired to publish list_changed events to listen streams. The - # fixture is armed in a follow-up change, after which this entry burns - # down. - - server-stateless # SEP-2243 (HTTP header standardization): the reject cells the SDK does # answer now use -32001 (HeaderMismatch), but missing-header enforcement # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 535b4e1221..5429e3be57 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -967,6 +967,43 @@ function createMcpServer() { } ); + // ===== SUBSCRIPTION/LISTEN DIAGNOSTIC TRIGGERS (SEP-2575) ===== + // + // The `server-stateless` conformance scenario opens a `subscriptions/listen` + // stream (served by `createMcpHandler`'s built-in listen router), then calls + // one of these triggers and asserts the corresponding `*/list_changed` + // notification arrives on the open stream. The trigger publishes the change + // event onto the handler's bus via the `handler.notify.*` sugar — the + // listen router stamps the subscription id and applies the per-stream + // filter, so the same trigger also exercises the ack-first and + // honors-notification-filter checks. The 2026-07-28 path is per-request + // (each call gets a fresh `McpServer`), so there is no list to mutate; the + // event itself is what the SHOULD requirement measures. + + mcpServer.registerTool( + 'test_trigger_tool_change', + { + description: 'Listen diagnostic (SEP-2575): publishes a tools/list_changed event onto the handler bus', + inputSchema: z.object({}) + }, + async (): Promise => { + modernHandler.notify.toolsChanged(); + return { content: [{ type: 'text', text: 'tools_list_changed published' }] }; + } + ); + + mcpServer.registerTool( + 'test_trigger_prompt_change', + { + description: 'Listen diagnostic (SEP-2575): publishes a prompts/list_changed event onto the handler bus', + inputSchema: z.object({}) + }, + async (): Promise => { + modernHandler.notify.promptsChanged(); + return { content: [{ type: 'text', text: 'prompts_list_changed published' }] }; + } + ); + // ===== RESOURCES ===== // Static text resource From d28dcde41b675542e9dae26d5d85d5241ad11ec0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:57:50 +0100 Subject: [PATCH 32/37] =?UTF-8?q?docs:=20@deprecated=20JSDoc=20on=20push-s?= =?UTF-8?q?tyle=20server=E2=86=92client=20APIs=20that=20throw=20on=202026-?= =?UTF-8?q?07-28-era=20requests=20(#2326)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/shared/protocol.ts | 11 +++++++-- packages/server/src/server/server.ts | 35 +++++++++++++++++++++------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index eaf14d5c67..356d63ea08 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -387,6 +387,11 @@ export type ServerContext = BaseContext & { /** * Send an elicitation request to the client, requesting user input. + * + * @deprecated Throws on a 2026-07-28-era request — return `inputRequired(...)` + * (multi-round-trip) from the handler instead. The 2025 push-style server-to-client request model is + * replaced by input_required results in the 2026-07-28 protocol. If your factory serves + * both eras, this only works on the legacy path. */ elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; @@ -394,8 +399,10 @@ export type ServerContext = BaseContext & { * Request LLM sampling from the client. * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). - * Remains functional during the deprecation window (at least twelve months). - * Migrate to calling LLM provider APIs directly. + * Throws on a 2026-07-28-era request — return `inputRequired(...)` (multi-round-trip) + * from the handler instead, or migrate to calling LLM provider APIs directly. The 2025 push-style + * server-to-client request model is replaced by input_required results in the 2026-07-28 + * protocol. If your factory serves both eras, this only works on the legacy path. */ requestSampling: ( params: CreateMessageRequest['params'], diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4b056cf123..f5eccb2dba 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -849,6 +849,12 @@ export class Server extends Protocol { return this._capabilities; } + /** + * Sends a `ping` request to the connected client. + * + * @deprecated The 2026-07-28 protocol removed ping; it throws on a 2026-07-28-era instance. + * If your factory serves both eras, this only works on the legacy path. + */ async ping(): Promise { this._assertPushApiInServedEra('ping'); return this.request({ method: 'ping' }); @@ -859,8 +865,10 @@ export class Server extends Protocol { * Returns single content block for backwards compatibility. * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). - * Remains functional during the deprecation window (at least twelve months). - * Migrate to calling LLM provider APIs directly. + * Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) instead, + * or migrate to calling LLM provider APIs directly. The 2025 push-style server-to-client + * request model is replaced by input_required results in the 2026-07-28 protocol. If your + * factory serves both eras, this only works on the legacy path. */ async createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; @@ -869,8 +877,10 @@ export class Server extends Protocol { * Returns content that may be a single block or array (for parallel tool calls). * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). - * Remains functional during the deprecation window (at least twelve months). - * Migrate to calling LLM provider APIs directly. + * Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) instead, + * or migrate to calling LLM provider APIs directly. The 2025 push-style server-to-client + * request model is replaced by input_required results in the 2026-07-28 protocol. If your + * factory serves both eras, this only works on the legacy path. */ async createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; @@ -879,8 +889,10 @@ export class Server extends Protocol { * When tools may or may not be present, returns the union type. * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). - * Remains functional during the deprecation window (at least twelve months). - * Migrate to calling LLM provider APIs directly. + * Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) instead, + * or migrate to calling LLM provider APIs directly. The 2025 push-style server-to-client + * request model is replaced by input_required results in the 2026-07-28 protocol. If your + * factory serves both eras, this only works on the legacy path. */ async createMessage( params: CreateMessageRequest['params'], @@ -960,6 +972,11 @@ export class Server extends Protocol { * @param params The parameters for the elicitation request. * @param options Optional request options. * @returns The result of the elicitation request. + * + * @deprecated Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) + * instead. The 2025 push-style server-to-client request model is replaced by input_required + * results in the 2026-07-28 protocol. If your factory serves both eras, this only works on the + * legacy path. */ async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { this._assertPushApiInServedEra('elicitation/create'); @@ -1049,8 +1066,10 @@ export class Server extends Protocol { * Requests the list of roots from the client. * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). - * Remains functional during the deprecation window (at least twelve months). - * Migrate to passing paths via tool parameters, resource URIs, or configuration. + * Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) instead, + * or migrate to passing paths via tool parameters, resource URIs, or configuration. The 2025 + * push-style server-to-client request model is replaced by input_required results in the + * 2026-07-28 protocol. If your factory serves both eras, this only works on the legacy path. */ async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise { this._assertPushApiInServedEra('roots/list'); From 4a1ef3622771343b7e41bbd57038d2117c554017 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:31:53 +0100 Subject: [PATCH 33/37] refactor(examples): per-story directory layout, self-verifying CI harness, and the start-here / capstone story set (#2325) --- .changeset/config.json | 5 +- .changeset/fix-session-status-codes.md | 5 - .changeset/pre.json | 4 +- .github/workflows/examples.yml | 44 + .prettierrc.json | 3 +- CLAUDE.md | 14 +- CONTRIBUTING.md | 13 +- README.md | 3 +- docs/client-quickstart.md | 2 +- docs/client.md | 170 ++-- docs/faq.md | 2 +- docs/server-quickstart.md | 2 +- docs/server.md | 135 +-- examples/README.md | 170 ++++ examples/bearer-auth/README.md | 6 + examples/bearer-auth/client.ts | 29 + examples/bearer-auth/package.json | 26 + examples/bearer-auth/server.ts | 70 ++ examples/caching/README.md | 10 + examples/caching/client.ts | 32 + examples/caching/package.json | 19 + examples/caching/server.ts | 53 ++ examples/client/README.md | 52 -- examples/client/eslint.config.mjs | 14 - examples/client/package.json | 47 - examples/client/src/customMethodExample.ts | 25 - examples/client/src/dualEraStdioClient.ts | 56 -- examples/client/src/elicitationUrlExample.ts | 824 ------------------ .../client/src/multipleClientsParallel.ts | 152 ---- .../client/src/parallelToolCallsClient.ts | 175 ---- .../client/src/simpleClientCredentials.ts | 83 -- examples/client/src/ssePollingClient.ts | 109 --- .../streamableHttpWithSseFallbackClient.ts | 181 ---- examples/client/tsconfig.json | 22 - examples/client/tsdown.config.ts | 25 - examples/client/vitest.config.js | 3 - examples/custom-methods/README.md | 8 + examples/custom-methods/client.ts | 32 + examples/custom-methods/package.json | 20 + examples/custom-methods/server.ts | 29 + examples/custom-version/README.md | 7 + examples/custom-version/client.ts | 22 + examples/custom-version/package.json | 19 + examples/custom-version/server.ts | 27 + examples/dual-era/README.md | 12 + examples/dual-era/client.ts | 37 + examples/dual-era/package.json | 20 + examples/dual-era/server.ts | 44 + examples/elicitation/README.md | 20 + examples/elicitation/client.ts | 92 ++ examples/elicitation/package.json | 21 + examples/elicitation/server.ts | 223 +++++ examples/eslint.config.mjs | 43 + examples/guides/README.md | 3 + .../src => guides}/clientGuide.examples.ts | 0 .../src => guides}/serverGuide.examples.ts | 0 examples/harness.ts | 218 +++++ examples/hono/README.md | 6 + examples/hono/client.ts | 20 + examples/hono/package.json | 27 + examples/hono/server.ts | 34 + examples/json-response/README.md | 5 + examples/json-response/client.ts | 45 + examples/json-response/package.json | 24 + examples/json-response/server.ts | 30 + examples/legacy-routing/README.md | 26 + examples/legacy-routing/client.ts | 27 + examples/legacy-routing/package.json | 29 + examples/legacy-routing/server.ts | 93 ++ examples/mrtr/README.md | 10 + .../client.ts} | 68 +- examples/mrtr/package.json | 21 + .../src/multiRoundTrip.ts => mrtr/server.ts} | 28 +- examples/oauth-client-credentials/README.md | 41 + examples/oauth-client-credentials/client.ts | 58 ++ .../oauth-client-credentials/package.json | 27 + examples/oauth-client-credentials/server.ts | 76 ++ examples/oauth/README.md | 28 + examples/oauth/client.ts | 151 ++++ .../{client/src => oauth}/dualModeAuth.ts | 2 +- examples/oauth/package.json | 34 + examples/oauth/server.ts | 82 ++ .../src => oauth}/simpleOAuthClient.ts | 9 +- .../simpleOAuthClientProvider.ts | 0 .../src => oauth}/simpleTokenProvider.ts | 4 +- examples/{server => }/package.json | 29 +- examples/parallel-calls/README.md | 5 + examples/parallel-calls/client.ts | 49 ++ examples/parallel-calls/package.json | 17 + examples/parallel-calls/server.ts | 37 + examples/prompts/README.md | 7 + examples/prompts/client.ts | 25 + examples/prompts/package.json | 16 + examples/prompts/server.ts | 42 + examples/repl/README.md | 13 + .../client.ts} | 0 examples/repl/package.json | 26 + examples/repl/server.ts | 285 ++++++ examples/resources/README.md | 7 + examples/resources/client.ts | 25 + examples/resources/package.json | 15 + examples/resources/server.ts | 35 + examples/sampling/README.md | 19 + examples/sampling/client.ts | 29 + examples/sampling/package.json | 20 + examples/sampling/server.ts | 65 ++ examples/schema-validators/README.md | 7 + examples/schema-validators/client.ts | 29 + examples/schema-validators/package.json | 19 + examples/schema-validators/server.ts | 55 ++ examples/server/README.md | 173 ---- examples/server/eslint.config.mjs | 14 - examples/server/src/arktypeExample.ts | 29 - examples/server/src/customMethodExample.ts | 23 - examples/server/src/customProtocolVersion.ts | 65 -- examples/server/src/dualEraStdio.ts | 69 -- examples/server/src/dualEraStreamableHttp.ts | 93 -- examples/server/src/elicitationFormExample.ts | 488 ----------- examples/server/src/elicitationUrlExample.ts | 738 ---------------- .../src/honoWebStandardStreamableHttp.ts | 73 -- .../server/src/jsonResponseStreamableHttp.ts | 168 ---- examples/server/src/mcpServerOutputSchema.ts | 83 -- examples/server/src/resourceServerOnly.ts | 87 -- .../src/simpleStatelessStreamableHttp.ts | 171 ---- examples/server/src/simpleStreamableHttp.ts | 658 -------------- .../src/standaloneSseWithGetStreamableHttp.ts | 168 ---- examples/server/src/toolWithSampleServer.ts | 60 -- examples/server/src/valibotExample.ts | 31 - examples/server/tsdown.config.ts | 25 - examples/server/vitest.config.js | 3 - examples/shared/package.json | 11 +- examples/shared/src/authServer.ts | 45 +- .../shared/src/clientCredentialsAuthServer.ts | 135 +++ .../src/inMemoryEventStore.ts | 0 examples/shared/src/index.ts | 7 + examples/sse-polling/README.md | 12 + examples/sse-polling/client.ts | 51 ++ examples/sse-polling/package.json | 30 + .../server.ts} | 73 +- examples/standalone-get/README.md | 8 + examples/standalone-get/client.ts | 43 + examples/standalone-get/package.json | 28 + examples/standalone-get/server.ts | 96 ++ examples/stateless-legacy/README.md | 5 + examples/stateless-legacy/client.ts | 23 + examples/stateless-legacy/package.json | 24 + examples/stateless-legacy/server.ts | 30 + examples/stickynotes/README.md | 7 + examples/stickynotes/client.ts | 89 ++ examples/stickynotes/package.json | 20 + examples/stickynotes/server.ts | 115 +++ examples/streaming/README.md | 8 + examples/streaming/client.ts | 46 + examples/streaming/package.json | 16 + examples/streaming/server.ts | 59 ++ examples/subscriptions/README.md | 16 + examples/subscriptions/client.ts | 76 ++ examples/subscriptions/package.json | 21 + examples/subscriptions/server.ts | 89 ++ examples/tools/README.md | 8 + examples/tools/client.ts | 40 + examples/tools/package.json | 16 + examples/tools/server.ts | 50 ++ examples/{server => }/tsconfig.json | 10 +- package.json | 3 +- pnpm-lock.yaml | 517 ++++++++++- pnpm-workspace.yaml | 2 + scripts/run-examples.ts | 214 +++++ typedoc.config.mjs | 2 +- 169 files changed, 5299 insertions(+), 5328 deletions(-) delete mode 100644 .changeset/fix-session-status-codes.md create mode 100644 .github/workflows/examples.yml create mode 100644 examples/README.md create mode 100644 examples/bearer-auth/README.md create mode 100644 examples/bearer-auth/client.ts create mode 100644 examples/bearer-auth/package.json create mode 100644 examples/bearer-auth/server.ts create mode 100644 examples/caching/README.md create mode 100644 examples/caching/client.ts create mode 100644 examples/caching/package.json create mode 100644 examples/caching/server.ts delete mode 100644 examples/client/README.md delete mode 100644 examples/client/eslint.config.mjs delete mode 100644 examples/client/package.json delete mode 100644 examples/client/src/customMethodExample.ts delete mode 100644 examples/client/src/dualEraStdioClient.ts delete mode 100644 examples/client/src/elicitationUrlExample.ts delete mode 100644 examples/client/src/multipleClientsParallel.ts delete mode 100644 examples/client/src/parallelToolCallsClient.ts delete mode 100644 examples/client/src/simpleClientCredentials.ts delete mode 100644 examples/client/src/ssePollingClient.ts delete mode 100644 examples/client/src/streamableHttpWithSseFallbackClient.ts delete mode 100644 examples/client/tsconfig.json delete mode 100644 examples/client/tsdown.config.ts delete mode 100644 examples/client/vitest.config.js create mode 100644 examples/custom-methods/README.md create mode 100644 examples/custom-methods/client.ts create mode 100644 examples/custom-methods/package.json create mode 100644 examples/custom-methods/server.ts create mode 100644 examples/custom-version/README.md create mode 100644 examples/custom-version/client.ts create mode 100644 examples/custom-version/package.json create mode 100644 examples/custom-version/server.ts create mode 100644 examples/dual-era/README.md create mode 100644 examples/dual-era/client.ts create mode 100644 examples/dual-era/package.json create mode 100644 examples/dual-era/server.ts create mode 100644 examples/elicitation/README.md create mode 100644 examples/elicitation/client.ts create mode 100644 examples/elicitation/package.json create mode 100644 examples/elicitation/server.ts create mode 100644 examples/eslint.config.mjs create mode 100644 examples/guides/README.md rename examples/{client/src => guides}/clientGuide.examples.ts (100%) rename examples/{server/src => guides}/serverGuide.examples.ts (100%) create mode 100644 examples/harness.ts create mode 100644 examples/hono/README.md create mode 100644 examples/hono/client.ts create mode 100644 examples/hono/package.json create mode 100644 examples/hono/server.ts create mode 100644 examples/json-response/README.md create mode 100644 examples/json-response/client.ts create mode 100644 examples/json-response/package.json create mode 100644 examples/json-response/server.ts create mode 100644 examples/legacy-routing/README.md create mode 100644 examples/legacy-routing/client.ts create mode 100644 examples/legacy-routing/package.json create mode 100644 examples/legacy-routing/server.ts create mode 100644 examples/mrtr/README.md rename examples/{client/src/multiRoundTripClient.ts => mrtr/client.ts} (50%) create mode 100644 examples/mrtr/package.json rename examples/{server/src/multiRoundTrip.ts => mrtr/server.ts} (85%) create mode 100644 examples/oauth-client-credentials/README.md create mode 100644 examples/oauth-client-credentials/client.ts create mode 100644 examples/oauth-client-credentials/package.json create mode 100644 examples/oauth-client-credentials/server.ts create mode 100644 examples/oauth/README.md create mode 100644 examples/oauth/client.ts rename examples/{client/src => oauth}/dualModeAuth.ts (99%) create mode 100644 examples/oauth/package.json create mode 100644 examples/oauth/server.ts rename examples/{client/src => oauth}/simpleOAuthClient.ts (97%) rename examples/{client/src => oauth}/simpleOAuthClientProvider.ts (100%) rename examples/{client/src => oauth}/simpleTokenProvider.ts (95%) rename examples/{server => }/package.json (60%) create mode 100644 examples/parallel-calls/README.md create mode 100644 examples/parallel-calls/client.ts create mode 100644 examples/parallel-calls/package.json create mode 100644 examples/parallel-calls/server.ts create mode 100644 examples/prompts/README.md create mode 100644 examples/prompts/client.ts create mode 100644 examples/prompts/package.json create mode 100644 examples/prompts/server.ts create mode 100644 examples/repl/README.md rename examples/{client/src/simpleStreamableHttp.ts => repl/client.ts} (100%) create mode 100644 examples/repl/package.json create mode 100644 examples/repl/server.ts create mode 100644 examples/resources/README.md create mode 100644 examples/resources/client.ts create mode 100644 examples/resources/package.json create mode 100644 examples/resources/server.ts create mode 100644 examples/sampling/README.md create mode 100644 examples/sampling/client.ts create mode 100644 examples/sampling/package.json create mode 100644 examples/sampling/server.ts create mode 100644 examples/schema-validators/README.md create mode 100644 examples/schema-validators/client.ts create mode 100644 examples/schema-validators/package.json create mode 100644 examples/schema-validators/server.ts delete mode 100644 examples/server/README.md delete mode 100644 examples/server/eslint.config.mjs delete mode 100644 examples/server/src/arktypeExample.ts delete mode 100644 examples/server/src/customMethodExample.ts delete mode 100644 examples/server/src/customProtocolVersion.ts delete mode 100644 examples/server/src/dualEraStdio.ts delete mode 100644 examples/server/src/dualEraStreamableHttp.ts delete mode 100644 examples/server/src/elicitationFormExample.ts delete mode 100644 examples/server/src/elicitationUrlExample.ts delete mode 100644 examples/server/src/honoWebStandardStreamableHttp.ts delete mode 100644 examples/server/src/jsonResponseStreamableHttp.ts delete mode 100644 examples/server/src/mcpServerOutputSchema.ts delete mode 100644 examples/server/src/resourceServerOnly.ts delete mode 100644 examples/server/src/simpleStatelessStreamableHttp.ts delete mode 100644 examples/server/src/simpleStreamableHttp.ts delete mode 100644 examples/server/src/standaloneSseWithGetStreamableHttp.ts delete mode 100644 examples/server/src/toolWithSampleServer.ts delete mode 100644 examples/server/src/valibotExample.ts delete mode 100644 examples/server/tsdown.config.ts delete mode 100644 examples/server/vitest.config.js create mode 100644 examples/shared/src/clientCredentialsAuthServer.ts rename examples/{server => shared}/src/inMemoryEventStore.ts (100%) create mode 100644 examples/sse-polling/README.md create mode 100644 examples/sse-polling/client.ts create mode 100644 examples/sse-polling/package.json rename examples/{server/src/ssePollingExample.ts => sse-polling/server.ts} (58%) create mode 100644 examples/standalone-get/README.md create mode 100644 examples/standalone-get/client.ts create mode 100644 examples/standalone-get/package.json create mode 100644 examples/standalone-get/server.ts create mode 100644 examples/stateless-legacy/README.md create mode 100644 examples/stateless-legacy/client.ts create mode 100644 examples/stateless-legacy/package.json create mode 100644 examples/stateless-legacy/server.ts create mode 100644 examples/stickynotes/README.md create mode 100644 examples/stickynotes/client.ts create mode 100644 examples/stickynotes/package.json create mode 100644 examples/stickynotes/server.ts create mode 100644 examples/streaming/README.md create mode 100644 examples/streaming/client.ts create mode 100644 examples/streaming/package.json create mode 100644 examples/streaming/server.ts create mode 100644 examples/subscriptions/README.md create mode 100644 examples/subscriptions/client.ts create mode 100644 examples/subscriptions/package.json create mode 100644 examples/subscriptions/server.ts create mode 100644 examples/tools/README.md create mode 100644 examples/tools/client.ts create mode 100644 examples/tools/package.json create mode 100644 examples/tools/server.ts rename examples/{server => }/tsconfig.json (68%) create mode 100644 scripts/run-examples.ts diff --git a/.changeset/config.json b/.changeset/config.json index eb43bdc7fd..6821c8c0ce 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,10 +8,9 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ - "@modelcontextprotocol/examples-client", + "@modelcontextprotocol/examples", "@modelcontextprotocol/examples-client-quickstart", - "@modelcontextprotocol/examples-server", "@modelcontextprotocol/examples-server-quickstart", - "@modelcontextprotocol/examples-shared" + "@mcp-examples/*" ] } diff --git a/.changeset/fix-session-status-codes.md b/.changeset/fix-session-status-codes.md deleted file mode 100644 index ff2a264bfc..0000000000 --- a/.changeset/fix-session-status-codes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/examples-server': patch ---- - -Example servers now return HTTP 404 (not 400) when a request includes an unknown session ID, so clients can correctly detect they need to start a new session. Requests missing a session ID entirely still return 400. diff --git a/.changeset/pre.json b/.changeset/pre.json index c4c3cf31a8..0fa4e3b738 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -5,11 +5,9 @@ "@modelcontextprotocol/eslint-config": "2.0.0", "@modelcontextprotocol/tsconfig": "2.0.0", "@modelcontextprotocol/vitest-config": "2.0.0", - "@modelcontextprotocol/examples-client": "2.0.0-alpha.0", + "@modelcontextprotocol/examples": "2.0.0-alpha.0", "@modelcontextprotocol/examples-client-quickstart": "2.0.0-alpha.0", - "@modelcontextprotocol/examples-server": "2.0.0-alpha.0", "@modelcontextprotocol/examples-server-quickstart": "2.0.0-alpha.0", - "@modelcontextprotocol/examples-shared": "2.0.0-alpha.0", "@modelcontextprotocol/client": "2.0.0-alpha.0", "@modelcontextprotocol/core": "2.0.0-alpha.0", "@modelcontextprotocol/express": "2.0.0-alpha.0", diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml new file mode 100644 index 0000000000..8fbb86063a --- /dev/null +++ b/.github/workflows/examples.yml @@ -0,0 +1,44 @@ +name: Examples + +on: + push: + branches: + - main + - v2-2026-07-28 + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Builds the workspace + examples and e2e-runs every examples// pair + # over every transport it supports. Each client.ts is a self-verifying test + # (asserts and exits non-zero on any mismatch). This is part of the per-PR + # gate basket — a red examples run blocks merge. + examples: + name: examples (build + e2e) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + run_install: false + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - run: pnpm install + + # The workspace packages the examples import resolve to built dists + # (the gap that killed an earlier examples smoke suite). + - run: pnpm run build:all + + - name: Run all example pairs (transport × era) + run: pnpm tsx scripts/run-examples.ts diff --git a/.prettierrc.json b/.prettierrc.json index 840a2c6b0b..b9a90b2951 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -13,7 +13,8 @@ { "files": "**/*.md", "options": { - "printWidth": 280 + "printWidth": 280, + "proseWrap": "preserve" } } ] diff --git a/CLAUDE.md b/CLAUDE.md index d5a188676a..88514bb58c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,11 +121,15 @@ Pluggable JSON Schema validation (`packages/core/src/validators/`): ### Examples -Runnable examples in `examples/`: - -- `examples/server/src/` - Various server configurations (stateful, stateless, OAuth, etc.) -- `examples/client/src/` - Client examples (basic, OAuth, parallel calls, etc.) -- `examples/shared/src/` - Shared utilities (OAuth demo provider, etc.) +Runnable examples in `examples//{server.ts,client.ts}` — each story is its own +`@mcp-examples/` workspace package and a self-verifying e2e test (the client connects, +asserts results, exits non-zero on mismatch). `pnpm run:examples` runs every story over its +configured transport×era legs; the `examples (build + e2e)` CI job is part of the per-PR gate +basket. See `examples/README.md` for the full story matrix. + +- `examples/harness.ts` — dual-transport scaffold (`connectFromArgs`, `runServerFromArgs`, `httpUrlFromArgs`, `runClient`) +- `examples/shared/` — `@mcp-examples/shared` package (demo OAuth provider, `InMemoryEventStore`) +- `examples/guides/` — typecheck-only snippet collections synced into `docs/{server,client}.md` ## Message Flow (Bidirectional Protocol) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 325330c15b..d3d64c4819 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,16 +112,19 @@ Then: ### Running Examples -See [`examples/server/README.md`](examples/server/README.md) and [`examples/client/README.md`](examples/client/README.md) for a full list of runnable examples. +See [`examples/README.md`](examples/README.md) for the full list of runnable examples — one self-verifying client/server pair per directory. Quick start: ```bash -# Run a server example -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts +# Run any story's server +pnpm --filter @mcp-examples/tools server -- --http --port 3000 -# Run a client example (in another terminal) -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleStreamableHttp.ts +# Run its client (in another terminal) +pnpm --filter @mcp-examples/tools client -- --http http://127.0.0.1:3000/ + +# Run every story over every transport × era leg +pnpm run:examples ``` ## Releasing v1.x Patches diff --git a/README.md b/README.md index 55d8fb9d47..7f81e7f4ee 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,7 @@ Ready to build something real? Follow the step-by-step quickstart tutorials: The complete code for each tutorial is in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/) and [`examples/client-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client-quickstart/). For more advanced runnable examples, see: -- [`examples/server/README.md`](examples/server/README.md) — server examples index -- [`examples/client/README.md`](examples/client/README.md) — client examples index +- [`examples/README.md`](examples/README.md) — runnable, self-verifying client/server example pairs (one story per directory) ## Documentation diff --git a/docs/client-quickstart.md b/docs/client-quickstart.md index 71b8a9e12a..f26324636f 100644 --- a/docs/client-quickstart.md +++ b/docs/client-quickstart.md @@ -420,5 +420,5 @@ If you see: Now that you have a working client, here are some ways to go further: - [**Client guide**](./client.md) — Add OAuth, middleware, sampling, and more to your client. -- [**Example clients**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client) — Browse runnable client examples. +- [**Example clients**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Browse runnable client examples. - [**FAQ**](./faq.md) — Troubleshoot common errors. diff --git a/docs/client.md b/docs/client.md index 042ba2861a..6b7c0df110 100644 --- a/docs/client.md +++ b/docs/client.md @@ -12,7 +12,7 @@ A client connects to a server, discovers what it offers — tools, resources, pr The examples below use these imports. Adjust based on which features and transport you need: -```ts source="../examples/client/src/clientGuide.examples.ts#imports" +```ts source="../examples/guides/clientGuide.examples.ts#imports" import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client'; import { applyMiddlewares, @@ -39,7 +39,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; For remote HTTP servers, use {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}: -```ts source="../examples/client/src/clientGuide.examples.ts#connect_streamableHttp" +```ts source="../examples/guides/clientGuide.examples.ts#connect_streamableHttp" const client = new Client({ name: 'my-client', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); @@ -47,13 +47,13 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 await client.connect(transport); ``` -For a full interactive client over Streamable HTTP, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). +For a full interactive client over Streamable HTTP, see [`repl/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/client.ts). ### stdio For local, process-spawned servers (Claude Desktop, CLI tools), use {@linkcode @modelcontextprotocol/client!client/stdio.StdioClientTransport | StdioClientTransport}. The transport spawns the server process and communicates over stdin/stdout: -```ts source="../examples/client/src/clientGuide.examples.ts#connect_stdio" +```ts source="../examples/guides/clientGuide.examples.ts#connect_stdio" const client = new Client({ name: 'my-client', version: '1.0.0' }); const transport = new StdioClientTransport({ @@ -66,9 +66,10 @@ await client.connect(transport); ### SSE fallback for legacy servers -To support both modern Streamable HTTP and legacy SSE servers, try {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport} first and fall back to {@linkcode @modelcontextprotocol/client!client/sse.SSEClientTransport | SSEClientTransport} on failure: +To support both modern Streamable HTTP and legacy SSE servers, try {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport} first and fall back to {@linkcode +@modelcontextprotocol/client!client/sse.SSEClientTransport | SSEClientTransport} on failure: -```ts source="../examples/client/src/clientGuide.examples.ts#connect_sseFallback" +```ts source="../examples/guides/clientGuide.examples.ts#connect_sseFallback" const baseUrl = new URL(url); try { @@ -86,13 +87,13 @@ try { } ``` -For a complete example with error reporting, see [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/streamableHttpWithSseFallbackClient.ts). +The snippet above is the complete pattern; wrap the `catch` body with whatever error reporting your host needs. ### Protocol version negotiation (2026-07-28 revision) By default the client negotiates a 2025-era protocol version via the `initialize` handshake — exactly the v1.x behavior, byte for byte. To talk to a server on the 2026-07-28 revision, opt into version negotiation via `ClientOptions.versionNegotiation`: -```ts source="../examples/client/src/clientGuide.examples.ts#Client_versionNegotiation" +```ts source="../examples/guides/clientGuide.examples.ts#Client_versionNegotiation" // Auto-negotiate: probe with server/discover, fall back to the 2025 handshake // against a 2025-only server. const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); @@ -106,7 +107,9 @@ client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' - **`mode: 'auto'`** — `connect()` probes with `server/discover`; a 2025-only server rejects the probe and the client falls back to the plain `initialize` handshake on the same connection, byte-equivalent to a 2025 client. The probe costs one round trip against an old server. - **`mode: { pin: '2026-07-28' }`** — modern era at exactly that revision; no fallback. Against a 2025-only server `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). -Once a modern era is negotiated, the client automatically attaches the per-request `_meta` envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [migration guide](./migration.md#opt-in-protocol-version-negotiation-2026-07-28-draft) for the full failure semantics, probe policy, and the `'auto'`-mode compatibility table. +Once a modern era is negotiated, the client automatically attaches the per-request `_meta` envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an +already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [migration guide](./migration.md#opt-in-protocol-version-negotiation-2026-07-28-draft) for the full failure semantics, +probe policy, and the `'auto'`-mode compatibility table. ### Disconnecting @@ -114,7 +117,7 @@ Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await For Streamable HTTP, terminate the server-side session first (per the MCP specification): -```ts source="../examples/client/src/clientGuide.examples.ts#disconnect_streamableHttp" +```ts source="../examples/guides/clientGuide.examples.ts#disconnect_streamableHttp" await transport.terminateSession(); // notify the server (recommended) await client.close(); ``` @@ -123,9 +126,10 @@ For stdio, `client.close()` handles graceful process shutdown (closes stdin, the ### Server instructions -Servers can provide an `instructions` string during initialization that describes how to use them — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Retrieve it after connecting and include it in the model's system prompt: +Servers can provide an `instructions` string during initialization that describes how to use them — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP +specification). Retrieve it after connecting and include it in the model's system prompt: -```ts source="../examples/client/src/clientGuide.examples.ts#serverInstructions_basic" +```ts source="../examples/guides/clientGuide.examples.ts#serverInstructions_basic" const instructions = client.getInstructions(); const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boolean).join('\n\n'); @@ -135,25 +139,27 @@ console.log(systemPrompt); ## Authentication -MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an {@linkcode @modelcontextprotocol/client!client/auth.AuthProvider | AuthProvider} to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. The transport calls `token()` before every request and `onUnauthorized()` (if provided) on 401, then retries once. +MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an {@linkcode @modelcontextprotocol/client!client/auth.AuthProvider | +AuthProvider} to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. The transport calls `token()` before every request and `onUnauthorized()` (if provided) on 401, then retries once. ### Bearer tokens -For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only `token()`. With no `onUnauthorized()`, a 401 throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} immediately: +For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only `token()`. With no `onUnauthorized()`, a 401 throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | +UnauthorizedError} immediately: -```ts source="../examples/client/src/clientGuide.examples.ts#auth_tokenProvider" +```ts source="../examples/guides/clientGuide.examples.ts#auth_tokenProvider" const authProvider: AuthProvider = { token: async () => getStoredToken() }; const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); ``` -See [`simpleTokenProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleTokenProvider.ts) for a complete runnable example. +See [`simpleTokenProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleTokenProvider.ts) for a complete runnable example. ### Client credentials {@linkcode @modelcontextprotocol/client!client/authExtensions.ClientCredentialsProvider | ClientCredentialsProvider} handles the `client_credentials` grant flow for service-to-service communication: -```ts source="../examples/client/src/clientGuide.examples.ts#auth_clientCredentials" +```ts source="../examples/guides/clientGuide.examples.ts#auth_clientCredentials" const authProvider = new ClientCredentialsProvider({ clientId: 'my-service', clientSecret: 'my-secret' @@ -170,7 +176,7 @@ await client.connect(transport); {@linkcode @modelcontextprotocol/client!client/authExtensions.PrivateKeyJwtProvider | PrivateKeyJwtProvider} signs JWT assertions for the `private_key_jwt` token endpoint auth method, avoiding a shared client secret: -```ts source="../examples/client/src/clientGuide.examples.ts#auth_privateKeyJwt" +```ts source="../examples/guides/clientGuide.examples.ts#auth_privateKeyJwt" const authProvider = new PrivateKeyJwtProvider({ clientId: 'my-service', privateKey: pemEncodedKey, @@ -180,23 +186,29 @@ const authProvider = new PrivateKeyJwtProvider({ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); ``` -For a runnable example supporting both auth methods via environment variables, see [`simpleClientCredentials.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleClientCredentials.ts). +For a runnable `client_credentials` example, see [`oauth-client-credentials/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth-client-credentials/client.ts) — its README shows the `private_key_jwt` swap (the in-repo demo Authorization +Server only implements `client_secret_basic`/`client_secret_post`, so there is no runnable `private_key_jwt` leg). ### Full OAuth with user authorization -For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect. +For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode +@modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, call {@linkcode +@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect. -For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts). +For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and +[`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts). ### Cross-App Access (Enterprise Managed Authorization) -{@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access protected MCP servers on their behalf. +{@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access +protected MCP servers on their behalf. This provider handles a two-step OAuth flow: + 1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange 2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant -```ts source="../examples/client/src/clientGuide.examples.ts#auth_crossAppAccess" +```ts source="../examples/guides/clientGuide.examples.ts#auth_crossAppAccess" const authProvider = new CrossAppAccessProvider({ assertion: async ctx => { // ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn @@ -220,26 +232,30 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 ``` The `assertion` callback receives a context object with: + - `authorizationServerUrl` – The MCP server's authorization server (discovered automatically) - `resourceUrl` – The MCP resource URL (discovered automatically) - `scope` – Optional scope passed to `auth()` or from `clientMetadata` - `fetchFn` – Fetch implementation to use for HTTP requests For manual control over the token exchange steps, use the Layer 2 utilities from `@modelcontextprotocol/client`: + - `requestJwtAuthorizationGrant()` – Exchange ID Token for JAG at IdP - `discoverAndRequestJwtAuthGrant()` – Discovery + JAG acquisition - `exchangeJwtAuthGrant()` – Exchange JAG for access token at MCP server > [!NOTE] -> See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth standards. +> See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth +> standards. ## Tools Tools are callable actions offered by servers — discovering and invoking them is usually how your client enables an LLM to take action (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. Results may be paginated — loop on `nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. Results may be paginated — loop on `nextCursor` to collect +all pages: -```ts source="../examples/client/src/clientGuide.examples.ts#callTool_basic" +```ts source="../examples/guides/clientGuide.examples.ts#callTool_basic" const allTools: Tool[] = []; let toolCursor: string | undefined; do { @@ -261,7 +277,7 @@ console.log(result.content); Tool results may include a `structuredContent` field — a machine-readable JSON object for programmatic use by the client application, complementing `content` which is for the LLM: -```ts source="../examples/client/src/clientGuide.examples.ts#callTool_structuredOutput" +```ts source="../examples/guides/clientGuide.examples.ts#callTool_structuredOutput" const result = await client.callTool({ name: 'calculate-bmi', arguments: { weightKg: 70, heightM: 1.75 } @@ -277,7 +293,7 @@ if (result.structuredContent) { Pass `onprogress` to receive incremental progress notifications from long-running tools. Use `resetTimeoutOnProgress` to keep the request alive while the server is actively reporting, and `maxTotalTimeout` as an absolute cap: -```ts source="../examples/client/src/clientGuide.examples.ts#callTool_progress" +```ts source="../examples/guides/clientGuide.examples.ts#callTool_progress" const result = await client.callTool( { name: 'long-operation', arguments: {} }, { @@ -295,9 +311,10 @@ console.log(result.content); Resources are read-only data — files, database schemas, configuration — that your application can retrieve from a server and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. Results may be paginated — loop on `nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. Results may be paginated — loop on +`nextCursor` to collect all pages: -```ts source="../examples/client/src/clientGuide.examples.ts#readResource_basic" +```ts source="../examples/guides/clientGuide.examples.ts#readResource_basic" const allResources: Resource[] = []; let resourceCursor: string | undefined; do { @@ -322,7 +339,7 @@ To discover URI templates for dynamic resources, use {@linkcode @modelcontextpro If the server supports resource subscriptions, use {@linkcode @modelcontextprotocol/client!client/client.Client#subscribeResource | subscribeResource()} to receive notifications when a resource changes, then re-read it: -```ts source="../examples/client/src/clientGuide.examples.ts#subscribeResource_basic" +```ts source="../examples/guides/clientGuide.examples.ts#subscribeResource_basic" await client.subscribeResource({ uri: 'config://app' }); client.setNotificationHandler('notifications/resources/updated', async notification => { @@ -340,9 +357,10 @@ await client.unsubscribeResource({ uri: 'config://app' }); Prompts are reusable message templates that servers offer to help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. Results may be paginated — loop on `nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. Results may be paginated — loop on +`nextCursor` to collect all pages: -```ts source="../examples/client/src/clientGuide.examples.ts#getPrompt_basic" +```ts source="../examples/guides/clientGuide.examples.ts#getPrompt_basic" const allPrompts: Prompt[] = []; let promptCursor: string | undefined; do { @@ -366,7 +384,7 @@ console.log(messages); Both prompts and resources can support argument completions. Use {@linkcode @modelcontextprotocol/client!client/client.Client#complete | complete()} to request autocompletion suggestions from the server as a user types: -```ts source="../examples/client/src/clientGuide.examples.ts#complete_basic" +```ts source="../examples/guides/clientGuide.examples.ts#complete_basic" const { completion } = await client.complete({ ref: { type: 'ref/prompt', @@ -384,9 +402,10 @@ console.log(completion.values); // e.g. ['typescript'] ### Automatic list-change tracking -The {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | listChanged} client option keeps a local cache of tools, prompts, or resources in sync with the server. It provides automatic server capability gating, debouncing (300 ms by default), auto-refresh, and error-first callbacks: +The {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | listChanged} client option keeps a local cache of tools, prompts, or resources in sync with the server. It provides automatic server capability gating, debouncing (300 ms by default), auto-refresh, and +error-first callbacks: -```ts source="../examples/client/src/clientGuide.examples.ts#listChanged_basic" +```ts source="../examples/guides/clientGuide.examples.ts#listChanged_basic" const client = new Client( { name: 'my-client', version: '1.0.0' }, { @@ -412,7 +431,7 @@ const client = new Client( For full control — or for notification types not covered by `listChanged` (such as log messages) — register handlers directly with {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()}: -```ts source="../examples/client/src/clientGuide.examples.ts#notificationHandler_basic" +```ts source="../examples/guides/clientGuide.examples.ts#notificationHandler_basic" // Server log messages (sent by the server during request processing) client.setNotificationHandler('notifications/message', notification => { const { level, data } = notification.params; @@ -427,11 +446,12 @@ client.setNotificationHandler('notifications/resources/list_changed', async () = ``` > [!WARNING] -> MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. +> MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the +> [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. To control the minimum severity of log messages the server sends, use {@linkcode @modelcontextprotocol/client!client/client.Client#setLoggingLevel | setLoggingLevel()}: -```ts source="../examples/client/src/clientGuide.examples.ts#setLoggingLevel_basic" +```ts source="../examples/guides/clientGuide.examples.ts#setLoggingLevel_basic" await client.setLoggingLevel('warning'); ``` @@ -440,9 +460,10 @@ await client.setLoggingLevel('warning'); ## Handling server-initiated requests -MCP is bidirectional — servers can send requests *to* the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). Declare the corresponding capability when constructing the {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and register a request handler: +MCP is bidirectional — servers can send requests _to_ the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). Declare the corresponding capability +when constructing the {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and register a request handler: -```ts source="../examples/client/src/clientGuide.examples.ts#capabilities_declaration" +```ts source="../examples/guides/clientGuide.examples.ts#capabilities_declaration" const client = new Client( { name: 'my-client', version: '1.0.0' }, { @@ -457,11 +478,12 @@ const client = new Client( ### Sampling > [!WARNING] -> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to calling LLM provider APIs directly. +> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers +> should migrate to calling LLM provider APIs directly. When a server needs an LLM completion during tool execution, it sends a `sampling/createMessage` request to the client (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Register a handler to fulfill it: -```ts source="../examples/client/src/clientGuide.examples.ts#sampling_handler" +```ts source="../examples/guides/clientGuide.examples.ts#sampling_handler" client.setRequestHandler('sampling/createMessage', async request => { const lastMessage = request.params.messages.at(-1); console.log('Sampling request:', lastMessage); @@ -480,9 +502,10 @@ client.setRequestHandler('sampling/createMessage', async request => { ### Elicitation -When a server needs user input during tool execution, it sends an `elicitation/create` request to the client (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). The client should present the form to the user and return the collected data, or `{ action: 'decline' }`: +When a server needs user input during tool execution, it sends an `elicitation/create` request to the client (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). The client should present the form to the user and return +the collected data, or `{ action: 'decline' }`: -```ts source="../examples/client/src/clientGuide.examples.ts#elicitation_handler" +```ts source="../examples/guides/clientGuide.examples.ts#elicitation_handler" client.setRequestHandler('elicitation/create', async request => { console.log('Server asks:', request.params.message); @@ -496,16 +519,18 @@ client.setRequestHandler('elicitation/create', async request => { }); ``` -For a full form-based elicitation handler with AJV validation, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). For URL elicitation mode, see [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/elicitationUrlExample.ts). +For a full form-based elicitation handler with AJV validation, see [`repl/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/client.ts). For URL elicitation mode (both the 2025-era push/throw style and the 2026-07-28 `inputRequired` +return), see [`elicitation/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/client.ts). ### Roots > [!WARNING] -> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to passing paths via tool parameters, resource URIs, or configuration. +> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> passing paths via tool parameters, resource URIs, or configuration. Roots let the client expose filesystem boundaries to the server (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Declare the `roots` capability and register a `roots/list` handler: -```ts source="../examples/client/src/clientGuide.examples.ts#roots_handler" +```ts source="../examples/guides/clientGuide.examples.ts#roots_handler" client.setRequestHandler('roots/list', async () => { return { roots: [ @@ -522,9 +547,9 @@ When the available roots change, notify the server with {@linkcode @modelcontext ### Tool errors vs protocol errors -{@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} has two error surfaces: the tool can *run but report failure* via `isError: true` in the result, or the *request itself can fail* and throw an exception. Always check both: +{@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} has two error surfaces: the tool can _run but report failure_ via `isError: true` in the result, or the _request itself can fail_ and throw an exception. Always check both: -```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_toolErrors" +```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_toolErrors" try { const result = await client.callTool({ name: 'fetch-data', @@ -550,13 +575,16 @@ try { } ``` -{@linkcode @modelcontextprotocol/client!index.ProtocolError | ProtocolError} represents JSON-RPC errors from the server (method not found, invalid params, internal error). {@linkcode @modelcontextprotocol/client!index.SdkError | SdkError} represents local SDK errors — {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | REQUEST_TIMEOUT}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.CapabilityNotSupported | CAPABILITY_NOT_SUPPORTED}, and others. +{@linkcode @modelcontextprotocol/client!index.ProtocolError | ProtocolError} represents JSON-RPC errors from the server (method not found, invalid params, internal error). {@linkcode @modelcontextprotocol/client!index.SdkError | SdkError} represents local SDK errors — {@linkcode +@modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | REQUEST_TIMEOUT}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.CapabilityNotSupported | +CAPABILITY_NOT_SUPPORTED}, and others. ### Connection lifecycle -Set {@linkcode @modelcontextprotocol/client!client/client.Client#onerror | client.onerror} to catch out-of-band transport errors (SSE disconnects, parse errors). Set {@linkcode @modelcontextprotocol/client!client/client.Client#onclose | client.onclose} to detect when the connection drops — pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error: +Set {@linkcode @modelcontextprotocol/client!client/client.Client#onerror | client.onerror} to catch out-of-band transport errors (SSE disconnects, parse errors). Set {@linkcode @modelcontextprotocol/client!client/client.Client#onclose | client.onclose} to detect when the +connection drops — pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error: -```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_lifecycle" +```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_lifecycle" // Out-of-band errors (SSE disconnects, parse errors) client.onerror = error => { console.error('Transport error:', error.message); @@ -570,9 +598,10 @@ client.onclose = () => { ### Timeouts -All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | SdkErrorCode.RequestTimeout}: +All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | +SdkErrorCode.RequestTimeout}: -```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_timeout" +```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_timeout" try { const result = await client.callTool( { name: 'slow-operation', arguments: {} }, @@ -588,9 +617,10 @@ try { ## Client middleware -Use {@linkcode @modelcontextprotocol/client!client/middleware.createMiddleware | createMiddleware()} and {@linkcode @modelcontextprotocol/client!client/middleware.applyMiddlewares | applyMiddlewares()} to compose fetch middleware pipelines. Middleware wraps the underlying `fetch` call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the `fetch` option: +Use {@linkcode @modelcontextprotocol/client!client/middleware.createMiddleware | createMiddleware()} and {@linkcode @modelcontextprotocol/client!client/middleware.applyMiddlewares | applyMiddlewares()} to compose fetch middleware pipelines. Middleware wraps the underlying `fetch` +call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the `fetch` option: -```ts source="../examples/client/src/clientGuide.examples.ts#middleware_basic" +```ts source="../examples/guides/clientGuide.examples.ts#middleware_basic" const authMiddleware = createMiddleware(async (next, input, init) => { const headers = new Headers(init?.headers); headers.set('X-Custom-Header', 'my-value'); @@ -604,11 +634,13 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 ## Trace context propagation -The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through both directions untouched — so you can propagate OpenTelemetry context across any transport, including stdio where HTTP headers are unavailable. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. +The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When +present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through both directions untouched — so you can propagate OpenTelemetry +context across any transport, including stdio where HTTP headers are unavailable. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. Attach trace context to a single request via `_meta`: -```ts source="../examples/client/src/clientGuide.examples.ts#traceContext_perRequest" +```ts source="../examples/guides/clientGuide.examples.ts#traceContext_perRequest" // Values would normally come from your tracer's active span context. const result = await client.callTool({ name: 'calculate-bmi', @@ -623,7 +655,7 @@ console.log(result.content); Or inject it into every outgoing request with fetch middleware (Streamable HTTP transport): -```ts source="../examples/client/src/clientGuide.examples.ts#traceContext_middleware" +```ts source="../examples/guides/clientGuide.examples.ts#traceContext_middleware" const traceContextMiddleware = createMiddleware(async (next, input, init) => { if (typeof init?.body !== 'string') { return next(input, init); @@ -658,7 +690,7 @@ On the server side, handlers can read the incoming trace context from `ctx.mcpRe When using SSE-based streaming, the server can assign event IDs. Pass `onresumptiontoken` to track them, and `resumptionToken` to resume from where you left off after a disconnection: -```ts source="../examples/client/src/clientGuide.examples.ts#resumptionToken_basic" +```ts source="../examples/guides/clientGuide.examples.ts#resumptionToken_basic" let lastToken: string | undefined; const result = await client.request( @@ -677,11 +709,11 @@ const result = await client.request( console.log(result); ``` -For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts). +For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`sse-polling/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts). ## See also -- [`examples/client/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client) — Full runnable client examples +- [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Full runnable client examples - [Server guide](./server.md) — Building MCP servers with this SDK - [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture) — Protocol-level concepts: participants, layers, primitives - [Migration guide](./migration.md) — Upgrading from previous SDK versions @@ -689,9 +721,9 @@ For an end-to-end example of server-initiated SSE disconnection and automatic cl ### Additional examples -| Feature | Description | Example | -|---------|-------------|---------| -| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/parallelToolCallsClient.ts) | -| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts) | -| Multiple clients | Independent client lifecycles to the same server | [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/multipleClientsParallel.ts) | -| URL elicitation | Handle sensitive data collection via browser | [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/elicitationUrlExample.ts) | +| Feature | Description | Example | +| ----------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallel-calls/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | +| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`sse-polling/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts) | +| Multiple clients | Independent client lifecycles to the same server | [`parallel-calls/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | +| URL elicitation | Handle sensitive data collection via browser | [`elicitation/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/client.ts) | diff --git a/docs/faq.md b/docs/faq.md index 5bc9d71c00..66f3d46c04 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -67,7 +67,7 @@ For production use, you can either: ### Where can I find runnable server examples? -The [server quickstart](./server-quickstart.md) walks you through building a weather server from scratch. Its complete source lives in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/). For more advanced examples (OAuth, streaming, sessions, etc.), see the server examples index in [`examples/server/README.md`](../examples/server/README.md). +The [server quickstart](./server-quickstart.md) walks you through building a weather server from scratch. Its complete source lives in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/). For more advanced examples (OAuth, streaming, sessions, etc.), see the server examples index in [`examples/README.md`](../examples/README.md). ### Where are the server auth helpers? diff --git a/docs/server-quickstart.md b/docs/server-quickstart.md index b8d19e7e1c..0ed198be18 100644 --- a/docs/server-quickstart.md +++ b/docs/server-quickstart.md @@ -472,5 +472,5 @@ This isn't an error - it just means there are no current weather alerts for that Now that your server is running locally, here are some ways to go further: - [**Server guide**](./server.md) — Add resources, prompts, logging, error handling, and remote transports to your server. -- [**Example servers**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server) — Browse runnable examples covering OAuth, streaming, sessions, and more. +- [**Example servers**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Browse runnable examples covering OAuth, streaming, sessions, and more. - [**FAQ**](./faq.md) — Troubleshoot common errors (Zod version conflicts, transport issues, etc.). diff --git a/docs/server.md b/docs/server.md index 53bd3e6051..a66bffd079 100644 --- a/docs/server.md +++ b/docs/server.md @@ -16,7 +16,7 @@ Building a server takes three steps: The examples below use these imports. Adjust based on which features and transport you need: -```ts source="../examples/server/src/serverGuide.examples.ts#imports" +```ts source="../examples/guides/serverGuide.examples.ts#imports" import { randomUUID } from 'node:crypto'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; @@ -38,7 +38,7 @@ MCP supports two transport mechanisms (see [Transport layer](https://modelcontex Create a {@linkcode @modelcontextprotocol/node!streamableHttp.NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} and connect it to your server: -```ts source="../examples/server/src/serverGuide.examples.ts#streamableHttp_stateful" +```ts source="../examples/guides/serverGuide.examples.ts#streamableHttp_stateful" const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new NodeStreamableHTTPServerTransport({ @@ -50,13 +50,13 @@ await server.connect(transport); **Options:** Set `sessionIdGenerator` to a function (shown above) for stateful sessions. Set it to `undefined` for stateless mode (simpler, but does not support resumability). Set `enableJsonResponse: true` to return plain JSON instead of SSE streams. -For a complete server with sessions, logging, and CORS mounted on Express, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). +For a complete server with sessions and the browser-client CORS recipe, see [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts). ### stdio For local, process-spawned integrations, use {@linkcode @modelcontextprotocol/server!server/stdio.StdioServerTransport | StdioServerTransport}: -```ts source="../examples/server/src/serverGuide.examples.ts#stdio_basic" +```ts source="../examples/guides/serverGuide.examples.ts#stdio_basic" const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new StdioServerTransport(); await server.connect(transport); @@ -64,8 +64,8 @@ await server.connect(transport); #### Serving the 2026-07-28 draft revision on stdio -A hand-constructed stdio server speaks the 2025-era protocol it was written for: nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision goes through the connection-pinned `serveStdio` entry, which mirrors `createMcpHandler` -for long-lived connections — the entry owns the transport and the era decision, and one instance from your factory serves the era the client opened the connection with: +A hand-constructed stdio server speaks the 2025-era protocol it was written for: nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision goes through the connection-pinned `serveStdio` entry, which mirrors `createMcpHandler` for +long-lived connections — the entry owns the transport and the era decision, and one instance from your factory serves the era the client opened the connection with: ```typescript import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -77,15 +77,16 @@ serveStdio(() => { }); ``` -Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. Pass `legacy: 'reject'` to -refuse 2025-era openings with the unsupported-protocol-version error. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for -details). A runnable example lives at `examples/server/src/dualEraStdio.ts`, with a two-legged client at `examples/client/src/dualEraStdioClient.ts`. +Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. Pass `legacy: 'reject'` to refuse +2025-era openings with the unsupported-protocol-version error. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for details). A runnable +example lives at `examples/dual-era/server.ts`, with a two-legged client at `examples/dual-era/client.ts`. ## Server instructions -Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to the system prompt. Instructions should not duplicate information already in tool descriptions. +Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to +the system prompt. Instructions should not duplicate information already in tool descriptions. -```ts source="../examples/server/src/serverGuide.examples.ts#instructions_basic" +```ts source="../examples/guides/serverGuide.examples.ts#instructions_basic" const server = new McpServer( { name: 'db-server', version: '1.0.0' }, { @@ -101,7 +102,7 @@ Tools let clients invoke actions on your server — they are usually the main wa Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (Zod) to validate arguments, and optionally an `outputSchema` for structured return values: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_basic" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_basic" server.registerTool( 'calculate-bmi', { @@ -127,8 +128,10 @@ server.registerTool( > When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`: > > ```ts -> type BmiResult = { bmi: number }; // assignable -> interface BmiResult { bmi: number } // type error +> type BmiResult = { bmi: number }; // assignable +> interface BmiResult { +> bmi: number; +> } // type error > ``` > > Alternatively, spread the value: `structuredContent: { ...result }`. @@ -137,7 +140,7 @@ server.registerTool( Tools can return `resource_link` content items to reference large resources without embedding them, letting clients fetch only what they need: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_resourceLink" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_resourceLink" server.registerTool( 'list-files', { @@ -168,7 +171,7 @@ server.registerTool( Tools can include annotations that hint at their behavior — whether a tool is read-only, destructive, or idempotent. Annotations help clients present tools appropriately without changing execution semantics: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_annotations" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_annotations" server.registerTool( 'delete-file', { @@ -191,7 +194,7 @@ server.registerTool( Return `isError: true` to report tool-level errors. The LLM sees these and can self-correct, unlike protocol-level errors which are hidden from it: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_errorHandling" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_errorHandling" server.registerTool( 'fetch-data', { @@ -223,11 +226,12 @@ If a handler throws instead of returning `isError`, the SDK catches the exceptio ## Resources -Resources expose read-only data — files, database schemas, configuration — that the host application can retrieve and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). Unlike [tools](#tools), which the LLM invokes on its own, resources are application-controlled: the host decides which resources to fetch and how to present them. +Resources expose read-only data — files, database schemas, configuration — that the host application can retrieve and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). Unlike +[tools](#tools), which the LLM invokes on its own, resources are application-controlled: the host decides which resources to fetch and how to present them. A static resource at a fixed URI: -```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_static" +```ts source="../examples/guides/serverGuide.examples.ts#registerResource_static" server.registerResource( 'config', 'config://app', @@ -244,7 +248,7 @@ server.registerResource( Dynamic resources use {@linkcode @modelcontextprotocol/server!server/mcp.ResourceTemplate | ResourceTemplate} with URI patterns. The `list` callback lets clients discover available instances: -```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_template" +```ts source="../examples/guides/serverGuide.examples.ts#registerResource_template" server.registerResource( 'user-profile', new ResourceTemplate('user://{userId}/profile', { @@ -272,13 +276,15 @@ server.registerResource( ``` > [!IMPORTANT] -> **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked. +> **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within +> the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked. ## Prompts -Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use a [tool](#tools) when the LLM should decide when to call it. +Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use +a [tool](#tools) when the LLM should decide when to call it. -```ts source="../examples/server/src/serverGuide.examples.ts#registerPrompt_basic" +```ts source="../examples/guides/serverGuide.examples.ts#registerPrompt_basic" server.registerPrompt( 'review-code', { @@ -306,7 +312,7 @@ server.registerPrompt( Both prompts and resources can support argument completions. Wrap a field in the `argsSchema` with {@linkcode @modelcontextprotocol/server!server/completable.completable | completable()} to provide autocompletion suggestions: -```ts source="../examples/server/src/serverGuide.examples.ts#registerPrompt_completion" +```ts source="../examples/guides/serverGuide.examples.ts#registerPrompt_completion" server.registerPrompt( 'review-code', { @@ -335,19 +341,20 @@ server.registerPrompt( ## Logging > [!WARNING] -> MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to stderr logging (STDIO servers) or OpenTelemetry. +> MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate +> to stderr logging (STDIO servers) or OpenTelemetry. Logging lets your server send structured diagnostics — debug traces, progress updates, warnings — to the connected client as notifications (see [Logging](https://modelcontextprotocol.io/specification/latest/server/utilities/logging) in the MCP specification). Declare the `logging` capability, then call `ctx.mcpReq.log(level, data)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside any handler: -```ts source="../examples/server/src/serverGuide.examples.ts#logging_capability" +```ts source="../examples/guides/serverGuide.examples.ts#logging_capability" const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); ``` Then log from any handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_logging" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_logging" server.registerTool( 'fetch-data', { @@ -370,7 +377,7 @@ Progress notifications let a tool report incremental status updates during long- If the client includes a `progressToken` in the request `_meta`, send `notifications/progress` via `ctx.mcpReq.notify()` (from {@linkcode @modelcontextprotocol/server!index.BaseContext | BaseContext}): -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_progress" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_progress" server.registerTool( 'process-files', { @@ -405,11 +412,13 @@ server.registerTool( ## Trace context propagation -The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through untouched on any transport, including stdio. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. +The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When +present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through untouched on any transport, including stdio. The key names are +exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. Read the caller's trace context from `ctx.mcpReq._meta` in a handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_traceContext" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_traceContext" server.registerTool( 'traced-operation', { @@ -433,18 +442,20 @@ To propagate context onward (for example on a server-initiated sampling request, ## Server-initiated requests -MCP is bidirectional — servers can send requests *to* the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). +MCP is bidirectional — servers can send requests _to_ the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). ### Sampling > [!WARNING] -> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to calling LLM provider APIs directly from your server. +> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> calling LLM provider APIs directly from your server. -Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling when a tool needs the model to generate or transform text mid-execution. +Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling +when a tool needs the model to generate or transform text mid-execution. Call `ctx.mcpReq.requestSampling(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_sampling" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_sampling" server.registerTool( 'summarize', { @@ -476,7 +487,7 @@ server.registerTool( ); ``` -For a full runnable example, see [`toolWithSampleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/toolWithSampleServer.ts). +For a full runnable example, see [`sampling/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sampling/server.ts). ### Elicitation @@ -490,7 +501,7 @@ Elicitation lets a tool handler request direct input from the user — form fiel Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_elicitation" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_elicitation" server.registerTool( 'collect-feedback', { @@ -530,16 +541,19 @@ server.registerTool( ); ``` -For runnable examples, see [`elicitationFormExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/elicitationFormExample.ts) (form) and [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/elicitationUrlExample.ts) (URL). +For runnable examples, see [`elicitation/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/server.ts) (form + URL mode, both protocol eras) and +[`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts) (the secure `requestState` round-trip pattern). ### Roots > [!WARNING] -> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to passing paths via tool parameters, resource URIs, or configuration. +> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> passing paths via tool parameters, resource URIs, or configuration. -Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode @modelcontextprotocol/server!server/server.Server#listRoots | server.server.listRoots()} (requires the client to declare the `roots` capability): +Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode +@modelcontextprotocol/server!server/server.Server#listRoots | server.server.listRoots()} (requires the client to declare the `roots` capability): -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_roots" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_roots" server.registerTool( 'list-workspace-files', { @@ -558,7 +572,7 @@ server.registerTool( For stateful multi-session HTTP servers, capture the `http.Server` from `app.listen()` so you can stop accepting connections, then close each session transport: -```ts source="../examples/server/src/serverGuide.examples.ts#shutdown_statefulHttp" +```ts source="../examples/guides/serverGuide.examples.ts#shutdown_statefulHttp" // Capture the http.Server so it can be closed on shutdown const httpServer = app.listen(3000); @@ -578,24 +592,26 @@ Calling {@linkcode @modelcontextprotocol/server!index.Transport#close | transpor For stdio servers, {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#close | server.close()} is sufficient: -```ts source="../examples/server/src/serverGuide.examples.ts#shutdown_stdio" +```ts source="../examples/guides/serverGuide.examples.ts#shutdown_stdio" process.on('SIGINT', async () => { await server.close(); process.exit(0); }); ``` -For a complete multi-session server with shutdown handling, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). +For a complete multi-session server with shutdown handling, see [`repl/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/server.ts). ## Deployment ### DNS rebinding protection -Under normal circumstances, cross-origin browser restrictions limit what a malicious website can do to your localhost server. [DNS rebinding attacks](https://en.wikipedia.org/wiki/DNS_rebinding) get around those restrictions entirely by making the requests appear as same-origin, since the attacking domain resolves to localhost. Validating the host header on the server side protects against this scenario. **All localhost MCP servers should use DNS rebinding protection.** +Under normal circumstances, cross-origin browser restrictions limit what a malicious website can do to your localhost server. [DNS rebinding attacks](https://en.wikipedia.org/wiki/DNS_rebinding) get around those restrictions entirely by making the requests appear as same-origin, +since the attacking domain resolves to localhost. Validating the host header on the server side protects against this scenario. **All localhost MCP servers should use DNS rebinding protection.** -The recommended approach is to use {@linkcode @modelcontextprotocol/express!express.createMcpExpressApp | createMcpExpressApp()} (from `@modelcontextprotocol/express`) or {@linkcode @modelcontextprotocol/hono!hono.createMcpHonoApp | createMcpHonoApp()} (from `@modelcontextprotocol/hono`), which enable Host header validation by default: +The recommended approach is to use {@linkcode @modelcontextprotocol/express!express.createMcpExpressApp | createMcpExpressApp()} (from `@modelcontextprotocol/express`) or {@linkcode @modelcontextprotocol/hono!hono.createMcpHonoApp | createMcpHonoApp()} (from +`@modelcontextprotocol/hono`), which enable Host header validation by default: -```ts source="../examples/server/src/serverGuide.examples.ts#dnsRebinding_basic" +```ts source="../examples/guides/serverGuide.examples.ts#dnsRebinding_basic" // Default: DNS rebinding protection auto-enabled (host is 127.0.0.1) const app = createMcpExpressApp(); @@ -608,7 +624,7 @@ const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); When binding to `0.0.0.0` / `::`, provide an allow-list of hosts: -```ts source="../examples/server/src/serverGuide.examples.ts#dnsRebinding_allowedHosts" +```ts source="../examples/guides/serverGuide.examples.ts#dnsRebinding_allowedHosts" const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['localhost', '127.0.0.1', 'myhost.local'] @@ -617,15 +633,16 @@ const app = createMcpExpressApp({ `createMcpHonoApp()` from `@modelcontextprotocol/hono` provides the same protection for Hono-based servers and Web Standard runtimes (Cloudflare Workers, Deno, Bun). -The app factories also validate the `Origin` header with the same arming rules: localhost-class binds are protected by default, and an explicit `allowedOrigins` list (hostnames, port-agnostic — the same convention as `allowedHosts`) replaces the default localhost allowlist; there is no option that disables Origin validation for a localhost-class bind. Requests without -an `Origin` header always pass, so MCP clients outside a browser are unaffected; a present `Origin` that is not allowed, or that cannot be parsed, is rejected with `403`. The per-framework middleware (`originValidation`, `localhostOriginValidation`) can also be mounted -explicitly, and `@modelcontextprotocol/node` ships equivalent request guards for plain `node:http` servers. +The app factories also validate the `Origin` header with the same arming rules: localhost-class binds are protected by default, and an explicit `allowedOrigins` list (hostnames, port-agnostic — the same convention as `allowedHosts`) replaces the default localhost allowlist; there +is no option that disables Origin validation for a localhost-class bind. Requests without an `Origin` header always pass, so MCP clients outside a browser are unaffected; a present `Origin` that is not allowed, or that cannot be parsed, is rejected with `403`. The per-framework +middleware (`originValidation`, `localhostOriginValidation`) can also be mounted explicitly, and `@modelcontextprotocol/node` ships equivalent request guards for plain `node:http` servers. -If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) middleware source for reference. When mounting a handler bare on a fetch-native runtime, the framework-agnostic helpers from `@modelcontextprotocol/server` (`hostHeaderValidationResponse`, `originValidationResponse`) cover the same checks before the request reaches the handler. +If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) +middleware source for reference. When mounting a handler bare on a fetch-native runtime, the framework-agnostic helpers from `@modelcontextprotocol/server` (`hostHeaderValidationResponse`, `originValidationResponse`) cover the same checks before the request reaches the handler. ## See also -- [`examples/server/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server) — Full runnable server examples +- [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Full runnable server examples - [Client guide](./client.md) — Building MCP clients with this SDK - [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture) — Protocol-level concepts: participants, layers, primitives - [Migration guide](./migration.md) — Upgrading from previous SDK versions @@ -633,10 +650,10 @@ If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP frame ### Additional examples -| Feature | Description | Example | -|---------|-------------|---------| -| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`honoWebStandardStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/honoWebStandardStreamableHttp.ts) | -| Session management | Per-session transport routing, initialization, and cleanup | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | -| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/inMemoryEventStore.ts) | -| CORS | Expose MCP headers for browser clients | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | -| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/server/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/README.md#multi-node-deployment-patterns) | +| Feature | Description | Example | +| ---------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`hono/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/hono/server.ts) | +| Session management | Per-session transport routing, initialization, and cleanup | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | +| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/shared/src/inMemoryEventStore.ts) | +| CORS | Expose MCP headers for browser clients | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | +| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/README.md#multi-node-deployment-patterns) | diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000..d41cd73a2e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,170 @@ +# MCP TypeScript SDK examples + +One **story** per directory. Every story is a runnable, self-verifying client/server pair: `server.ts` is what you would deploy, `client.ts` is what a host would write — it connects, exercises the feature with the public client API, asserts results, and exits 0. CI runs every +pair over every transport it supports (`scripts/run-examples.ts`); a non-zero exit fails the build. + +Each story is its own private workspace package (`@mcp-examples/`). Run any pair from the repo root: + +```bash +# stdio (the client spawns the server itself): +pnpm --filter @mcp-examples/ client + +# Streamable HTTP (two terminals): +pnpm --filter @mcp-examples/ server -- --http --port 3000 +pnpm --filter @mcp-examples/ client -- --http http://127.0.0.1:3000/mcp +``` + +Add `-- --legacy` to the client command for the 2025-era handshake. + +## Start here + +| Story | What it teaches | +| ------------------------------------- | ------------------------------------------------------------------------ | +| [`tools/`](./tools/README.md) | Register tools, infer input/output schemas, call them, structured output | +| [`prompts/`](./prompts/README.md) | Prompts + argument completion | +| [`resources/`](./resources/README.md) | Static + templated resources, list/read | +| [`dual-era/`](./dual-era/README.md) | One factory, both protocol eras, both transports | + +## Feature stories + +| Story | What it teaches | Transports | Era | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | -------------- | +| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern | +| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern | +| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual | +| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual | +| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | +| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | +| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | +| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | +| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | +| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) | +| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | +| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | +| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | + +## HTTP hosting variants + +| Story | What it teaches | Transports | Era | +| --------------------------------------------------- | ------------------------------------------------------------- | ---------- | -------------- | +| [`stateless-legacy/`](./stateless-legacy/README.md) | `createMcpHandler` default posture (the minimal deployment) | http | dual (in-body) | +| [`json-response/`](./json-response/README.md) | `createMcpHandler({ responseMode: 'json' })` | http | modern | +| [`hono/`](./hono/README.md) | `createMcpHandler(...).fetch` on Hono / web-standard runtimes | http | dual | +| [`sse-polling/`](./sse-polling/README.md) | SEP-1699 SSE polling/resumption (sessionful 2025) | http | legacy | +| [`standalone-get/`](./standalone-get/README.md) | Standalone GET stream + `listChanged` push (sessionful 2025) | http | legacy | + +`dual (in-body)` = the client connects to both eras inside one harness run; the story demonstrates one server serving both side by side. + +## Excluded + +| Directory | What it is | Why not in CI | +| ------------------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | +| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. | +| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | +| `shared/` | Demo OAuth provider helper library | Not a story — imported by the OAuth examples. | + +## Multi-node deployment patterns + +When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases: + +- **Stateless mode** - no need to maintain state between calls. +- **Persistent storage mode** - state stored in a database; any node can handle a session. +- **Local state with message routing** - stateful nodes + pub/sub routing for a session. + +### Stateless mode + +To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with: + +```typescript +sessionIdGenerator: undefined; +``` + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ +``` + +### Persistent storage mode + +Configure the transport with session management, but use an external event store: + +```typescript +sessionIdGenerator: () => randomUUID(), +eventStore: databaseEventStore +``` + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ + │ │ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────┐ +│ Database (PostgreSQL) │ +│ │ +│ • Session state │ +│ • Event storage for resumability │ +└─────────────────────────────────────────────┘ +``` + +### Streamable HTTP with distributed message routing + +For scenarios where local in-memory state must be maintained on specific nodes, combine Streamable HTTP with pub/sub routing so one node can terminate the client connection while another node owns the session state. + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │◄───►│ MCP Server #2 │ +│ (Has Session A) │ │ (Has Session B) │ +└─────────────────┘ └─────────────────────┘ + ▲│ ▲│ + │▼ │▼ +┌─────────────────────────────────────────────┐ +│ Message Queue / Pub-Sub │ +│ │ +│ • Session ownership registry │ +│ • Bidirectional message routing │ +│ • Request/response forwarding │ +└─────────────────────────────────────────────┘ +``` + +## Backwards compatibility (Streamable HTTP ↔ legacy SSE) + +A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows the [`connect_sseFallback`](../docs/client.md#sse-fallback-for-legacy-servers) recipe in the client guide — try +`StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the snippet in `guides/clientGuide.examples.ts` is the complete pattern. diff --git a/examples/bearer-auth/README.md b/examples/bearer-auth/README.md new file mode 100644 index 0000000000..834b1c8931 --- /dev/null +++ b/examples/bearer-auth/README.md @@ -0,0 +1,6 @@ +# bearer-auth + +Resource-server-only auth: `requireBearerAuth` + `mcpAuthMetadataRouter` from `@modelcontextprotocol/express` in front of `createMcpHandler`. The client asserts `401` + `WWW-Authenticate` without a token, and that the verified `authInfo` reaches the factory (`ctx.authInfo`) with +one. + +**HTTP-only** by definition. The full interactive OAuth set lives under `../oauth/` (run headlessly by the harness via the demo AS's auto-consent mode). diff --git a/examples/bearer-auth/client.ts b/examples/bearer-auth/client.ts new file mode 100644 index 0000000000..54554243af --- /dev/null +++ b/examples/bearer-auth/client.ts @@ -0,0 +1,29 @@ +/** + * Asserts a bare request is `401` with a `WWW-Authenticate` header, and that + * a request with `Authorization: Bearer demo-token` reaches the `whoami` tool + * with the verified `authInfo`. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; + +const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); + +runClient('bearer-auth', async () => { + // Unauthenticated → 401 + WWW-Authenticate. + const unauth = await fetch(URL, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) + }); + check.equal(unauth.status, 401); + check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/); + + // Authenticated → 200 and the tool sees the authInfo. Bearer auth is + // HTTP-layer and era-agnostic; `negotiationFromArgs()` honours `--legacy`. + const client = new Client({ name: 'bearer-auth-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL), { authProvider: { token: async () => 'demo-token' } })); + const result = await client.callTool({ name: 'whoami', arguments: {} }); + check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'client=demo-client'); + await client.close(); +}); diff --git a/examples/bearer-auth/package.json b/examples/bearer-auth/package.json new file mode 100644 index 0000000000..67e0a83eec --- /dev/null +++ b/examples/bearer-auth/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mcp-examples/bearer-auth", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "//": "Bearer auth + 401/WWW-Authenticate is HTTP-layer and era-agnostic; the client honours --legacy via negotiationFromArgs." + } +} diff --git a/examples/bearer-auth/server.ts b/examples/bearer-auth/server.ts new file mode 100644 index 0000000000..00e8d616ec --- /dev/null +++ b/examples/bearer-auth/server.ts @@ -0,0 +1,70 @@ +/** + * Minimal Resource-Server-only auth using the SDK's RS helpers + * (`mcpAuthMetadataRouter`, `requireBearerAuth`, `OAuthTokenVerifier`). + * + * No Authorization Server in this repo — the metadata points at a placeholder + * issuer; the token verifier accepts a single static `demo-token`. The MCP + * endpoint is hosted on `createMcpHandler` with the verified `authInfo` passed + * through to the factory (`ctx.authInfo`). HTTP-only by definition. + */ +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; +import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const PORT = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +const mcpServerUrl = new URL(`http://localhost:${PORT}/mcp`); + +const oauthMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] +}; + +// Replace with JWT verification, RFC 7662 introspection, etc. +const staticTokenVerifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + if (token !== 'demo-token') { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + } + return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 }; + } +}; + +const handler = createMcpHandler(ctx => { + const server = new McpServer({ name: 'bearer-auth-example', version: '1.0.0' }); + server.registerTool('whoami', { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, async () => ({ + content: [{ type: 'text', text: `client=${ctx.authInfo?.clientId ?? 'anon'}` }] + })); + return server; +}); + +const app = createMcpExpressApp(); +app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: mcpServerUrl, + resourceName: 'bearer-auth example' + }) +); +const auth = requireBearerAuth({ + verifier: staticTokenVerifier, + requiredScopes: ['mcp'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); +// `requireBearerAuth` sets `req.auth`; `handler.node` reads it and passes it +// to the factory as `ctx.authInfo`. +app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body)); + +app.listen(PORT, () => { + console.error(`bearer-auth example server on http://127.0.0.1:${PORT}/mcp`); +}); diff --git a/examples/caching/README.md b/examples/caching/README.md new file mode 100644 index 0000000000..665ee3cc7e --- /dev/null +++ b/examples/caching/README.md @@ -0,0 +1,10 @@ +# caching + +`CacheableResult` freshness hints (protocol revision 2026-07-28). The server declares hints at two layers — a per-registration `cacheHint` on the resource and server-level `ServerOptions.cacheHints` — and the SDK resolves most-specific-author-first (handler-return fields would +take precedence over both) and stamps `ttlMs`/`cacheScope` on the wire toward modern clients only. The client reads the stamped values back. + +> Full client-side cache **honouring** (re-using a still-fresh result instead of re-requesting) is a follow-up; this example reads what the server emits today. + +```bash +pnpm tsx examples/caching/client.ts +``` diff --git a/examples/caching/client.ts b/examples/caching/client.ts new file mode 100644 index 0000000000..f7137edf89 --- /dev/null +++ b/examples/caching/client.ts @@ -0,0 +1,32 @@ +/** + * Reads the cache hints emitted on cacheable results (2026-07-28 connections + * only) and asserts the configured values reached the wire. Full client-side + * cache *honouring* (re-using a fresh result instead of re-requesting) is a + * follow-up — see the SDK's tracking issue for client cache support. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +interface Cacheable { + ttlMs?: number; + cacheScope?: 'public' | 'private'; +} + +runClient('caching', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname); + check.equal(client.getNegotiatedProtocolVersion(), '2026-07-28'); + + const tools = (await client.listTools()) as Cacheable & Awaited>; + check.equal(tools.ttlMs, 30_000); + check.equal(tools.cacheScope, 'public'); + + const resources = (await client.listResources()) as Cacheable & Awaited>; + check.equal(resources.ttlMs, 5000); + check.equal(resources.cacheScope, 'public'); + + const read = (await client.readResource({ uri: 'config://app' })) as Cacheable & Awaited>; + check.equal(read.ttlMs, 60_000); + check.equal(read.cacheScope, 'private'); + + await client.close(); +}); diff --git a/examples/caching/package.json b/examples/caching/package.json new file mode 100644 index 0000000000..9b4623f1a3 --- /dev/null +++ b/examples/caching/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mcp-examples/caching", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "cacheHints (ttlMs / cacheScope on cacheable results) are emitted only toward 2026-era clients." + } +} diff --git a/examples/caching/server.ts b/examples/caching/server.ts new file mode 100644 index 0000000000..4b33f6fec7 --- /dev/null +++ b/examples/caching/server.ts @@ -0,0 +1,53 @@ +/** + * Cache hints (`CacheableResult`, protocol revision 2026-07-28). + * + * The 2026-07-28 revision requires `ttlMs`/`cacheScope` on the cacheable + * result types (the list operations and `resources/read`). The values are + * resolved most-specific-author-first: + * + * 1. fields the handler returns on the result itself, + * 2. a per-registration `cacheHint` (here: the resource's read result), + * 3. the server-level per-operation `ServerOptions.cacheHints`, + * 4. the conservative defaults (`ttlMs: 0`, `cacheScope: 'private'`). + * + * The fields are emitted ONLY toward 2026-era clients — a 2025-era response + * is byte-for-byte unchanged. One binary, either transport. + */ +import { McpServer } from '@modelcontextprotocol/server'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'caching-example', version: '1.0.0' }, + { + // Server-level per-operation hints: any list/read result that does not + // override a field gets these. + cacheHints: { + 'resources/list': { ttlMs: 5000, cacheScope: 'public' }, + 'tools/list': { ttlMs: 30_000, cacheScope: 'public' } + } + } + ); + + // A direct resource carrying a per-registration hint that wins for its + // own resources/read result. + server.registerResource( + 'app-config', + 'config://app', + { + mimeType: 'application/json', + description: 'Static application config (rarely changes)', + cacheHint: { ttlMs: 60_000, cacheScope: 'private' } + }, + async uri => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }] }) + ); + + // A tool, so tools/list has something to cache. + server.registerTool('noop', { description: 'no-op' }, async () => ({ content: [{ type: 'text', text: 'ok' }] })); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/client/README.md b/examples/client/README.md deleted file mode 100644 index 0879b3b6c0..0000000000 --- a/examples/client/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# MCP TypeScript SDK Examples (Client) - -This directory contains runnable MCP **client** examples built with `@modelcontextprotocol/client`. - -For server examples, see [`../server/README.md`](../server/README.md). For guided docs, see [`../../docs/client.md`](../../docs/client.md). - -## Running examples - -From the repo root: - -```bash -pnpm install -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleStreamableHttp.ts -``` - -Or, from within this package: - -```bash -cd examples/client -pnpm tsx src/simpleStreamableHttp.ts -``` - -Most clients expect a server to be running. Start one from [`../server/README.md`](../server/README.md) (for example `src/simpleStreamableHttp.ts` in `examples/server`). - -## Example index - -| Scenario | Description | File | -| --------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, and elicitation. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) | -| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) | -| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) | -| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) | -| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) | -| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | -| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | -| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Multi-round-trip client (2026-07-28) | Calls a write-once tool twice: default auto-fulfilment, then manual mode. | [`src/multiRoundTripClient.ts`](src/multiRoundTripClient.ts) | - -## URL elicitation example (server + client) - -Run the server first: - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/elicitationUrlExample.ts -``` - -Then run the client: - -```bash -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts -``` diff --git a/examples/client/eslint.config.mjs b/examples/client/eslint.config.mjs deleted file mode 100644 index 83b79879f6..0000000000 --- a/examples/client/eslint.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-check - -import baseConfig from '@modelcontextprotocol/eslint-config'; - -export default [ - ...baseConfig, - { - files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], - rules: { - // Allow console statements in examples only - 'no-console': 'off' - } - } -]; diff --git a/examples/client/package.json b/examples/client/package.json deleted file mode 100644 index 57b329fd2d..0000000000 --- a/examples/client/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@modelcontextprotocol/examples-client", - "private": true, - "version": "2.0.0-alpha.0", - "description": "Model Context Protocol implementation for TypeScript", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build:esm && pnpm run build:cjs", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "start": "pnpm run server", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "@modelcontextprotocol/client": "workspace:^", - "ajv": "catalog:runtimeShared", - "open": "^11.0.0", - "zod": "catalog:runtimeShared" - }, - "devDependencies": { - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/examples-shared": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "tsdown": "catalog:devTools" - } -} diff --git a/examples/client/src/customMethodExample.ts b/examples/client/src/customMethodExample.ts deleted file mode 100644 index a289af0a47..0000000000 --- a/examples/client/src/customMethodExample.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Custom (non-spec) method example: a client that sends `acme/search` and - * listens for `acme/searchProgress` notifications. - * - * Build `examples/server` first; this client spawns the server via stdio. - */ -import { Client } from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { z } from 'zod/v4'; - -const SearchResult = z.object({ items: z.array(z.string()) }); -const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); - -const client = new Client({ name: 'acme-search-client', version: '0.0.0' }); - -client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { - console.log(`[progress] ${params.stage} ${Math.round(params.pct * 100)}%`); -}); - -await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] })); - -const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); -console.log('items:', result.items); - -await client.close(); diff --git a/examples/client/src/dualEraStdioClient.ts b/examples/client/src/dualEraStdioClient.ts deleted file mode 100644 index 9a9f6fe864..0000000000 --- a/examples/client/src/dualEraStdioClient.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Drives the dual-era stdio server example (`examples/server/src/dualEraStdio.ts`, - * a `serveStdio` server) with both kinds of client, each over its own real - * child-process pipe: - * - * 1. a plain 2025 client — the `initialize` handshake, served exactly as today; - * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the - * `server/discover` probe negotiates the 2026-07-28 revision on the pipe - * (no `initialize` is ever sent), and the client attaches the per-request - * `_meta` envelope to every outgoing request itself. - * - * The client spawns the server example directly from source over stdio: - * - * tsx examples/client/src/dualEraStdioClient.ts - */ -import path from 'node:path'; - -import { Client } from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; - -// Spawn the sibling server example straight from its source (no build step), -// located relative to this file so the demo runs from any working directory. -const SERVER_SOURCE = path.resolve(import.meta.dirname, '../../server/src/dualEraStdio.ts'); -const SERVER = { command: 'npx', args: ['tsx', SERVER_SOURCE] }; - -async function legacyLeg(): Promise { - console.log('--- leg 1: plain 2025 client (initialize handshake) ---'); - const client = new Client({ name: 'legacy-demo-client', version: '1.0.0' }); - await client.connect(new StdioClientTransport(SERVER)); - - console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); - const tools = await client.listTools(); - console.log( - 'tools:', - tools.tools.map(tool => tool.name) - ); - const result = await client.callTool({ name: 'greet', arguments: { name: '2025 client' } }); - console.log('greet result:', JSON.stringify(result.content)); - await client.close(); -} - -async function modernLeg(): Promise { - console.log('--- leg 2: 2026-capable client (server/discover negotiation) ---'); - const client = new Client({ name: 'modern-demo-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); - await client.connect(new StdioClientTransport(SERVER)); - - console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); - - const result = await client.callTool({ name: 'greet', arguments: { name: '2026 client' } }); - console.log('greet result:', JSON.stringify(result.content)); - await client.close(); -} - -await legacyLeg(); -await modernLeg(); -console.log('both legs served by the same dual-era stdio server factory.'); diff --git a/examples/client/src/elicitationUrlExample.ts b/examples/client/src/elicitationUrlExample.ts deleted file mode 100644 index 7c5cce2ee2..0000000000 --- a/examples/client/src/elicitationUrlExample.ts +++ /dev/null @@ -1,824 +0,0 @@ -// Run with: pnpm tsx src/elicitationUrlExample.ts -// -// This example demonstrates how to use URL elicitation to securely -// collect user input in a remote (HTTP) server. -// URL elicitation allows servers to prompt the end-user to open a URL in their browser -// to collect sensitive information. - -import { createServer } from 'node:http'; -import { createInterface } from 'node:readline'; - -import type { - ElicitRequest, - ElicitRequestURLParams, - ElicitResult, - ListToolsRequest, - OAuthClientMetadata, - ResourceLink -} from '@modelcontextprotocol/client'; -import { - Client, - getDisplayName, - ProtocolError, - ProtocolErrorCode, - StreamableHTTPClientTransport, - UnauthorizedError, - UrlElicitationRequiredError -} from '@modelcontextprotocol/client'; -import open from 'open'; - -import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; - -// Set up OAuth (required for this example) -const OAUTH_CALLBACK_PORT = 8090; // Use different port than auth server (3001) -const OAUTH_CALLBACK_URL = `http://localhost:${OAUTH_CALLBACK_PORT}/callback`; - -console.log('Getting OAuth token...'); -const clientMetadata: OAuthClientMetadata = { - client_name: 'Elicitation MCP Client', - redirect_uris: [OAUTH_CALLBACK_URL], - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'client_secret_post', - scope: 'mcp:tools' -}; -const oauthProvider = new InMemoryOAuthClientProvider(OAUTH_CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { - console.log(`🌐 Opening browser for OAuth redirect: ${redirectUrl.toString()}`); - openBrowser(redirectUrl.toString()); -}); - -// Create readline interface for user input -const readline = createInterface({ - input: process.stdin, - output: process.stdout -}); -let abortCommand = new AbortController(); - -// Global client and transport for interactive commands -let client: Client | null = null; -let transport: StreamableHTTPClientTransport | null = null; -let serverUrl = 'http://localhost:3000/mcp'; -let sessionId: string | undefined; - -// Elicitation queue management -interface QueuedElicitation { - request: ElicitRequest; - resolve: (result: ElicitResult) => void; - reject: (error: Error) => void; -} - -let isProcessingCommand = false; -let isProcessingElicitations = false; -const elicitationQueue: QueuedElicitation[] = []; -let elicitationQueueSignal: (() => void) | null = null; -let elicitationsCompleteSignal: (() => void) | null = null; - -// Map to track pending URL elicitations waiting for completion notifications -const pendingURLElicitations = new Map< - string, - { - resolve: () => void; - reject: (error: Error) => void; - timeout: NodeJS.Timeout; - } ->(); - -async function main(): Promise { - console.log('MCP Interactive Client'); - console.log('====================='); - - // Connect to server immediately with default settings - await connect(); - - // Start the elicitation loop in the background - elicitationLoop().catch(error => { - console.error('Unexpected error in elicitation loop:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - }); - - // Short delay allowing the server to send any SSE elicitations on connection - await new Promise(resolve => setTimeout(resolve, 200)); - - // Wait until we are done processing any initial elicitations - await waitForElicitationsToComplete(); - - // Print help and start the command loop - printHelp(); - await commandLoop(); -} - -async function waitForElicitationsToComplete(): Promise { - // Wait until the queue is empty and nothing is being processed - while (elicitationQueue.length > 0 || isProcessingElicitations) { - await new Promise(resolve => setTimeout(resolve, 100)); - } -} - -function printHelp(): void { - console.log('\nAvailable commands:'); - console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); - console.log(' disconnect - Disconnect from server'); - console.log(' terminate-session - Terminate the current session'); - console.log(' reconnect - Reconnect to the server'); - console.log(' list-tools - List available tools'); - console.log(' call-tool [args] - Call a tool with optional JSON arguments'); - console.log(' payment-confirm - Test URL elicitation via error response with payment-confirm tool'); - console.log(' third-party-auth - Test tool that requires third-party OAuth credentials'); - console.log(' help - Show this help'); - console.log(' quit - Exit the program'); -} - -async function commandLoop(): Promise { - await new Promise(resolve => { - if (isProcessingElicitations) { - elicitationsCompleteSignal = resolve; - } else { - resolve(); - } - }); - - readline.question('\n> ', { signal: abortCommand.signal }, async input => { - isProcessingCommand = true; - - const args = input.trim().split(/\s+/); - const command = args[0]?.toLowerCase(); - - try { - switch (command) { - case 'connect': { - await connect(args[1]); - break; - } - - case 'disconnect': { - await disconnect(); - break; - } - - case 'terminate-session': { - await terminateSession(); - break; - } - - case 'reconnect': { - await reconnect(); - break; - } - - case 'list-tools': { - await listTools(); - break; - } - - case 'call-tool': { - if (args.length < 2) { - console.log('Usage: call-tool [args]'); - } else { - const toolName = args[1]!; - let toolArgs = {}; - if (args.length > 2) { - try { - toolArgs = JSON.parse(args.slice(2).join(' ')); - } catch { - console.log('Invalid JSON arguments. Using empty args.'); - } - } - await callTool(toolName, toolArgs); - } - break; - } - - case 'payment-confirm': { - await callPaymentConfirmTool(); - break; - } - - case 'third-party-auth': { - await callThirdPartyAuthTool(); - break; - } - - case 'help': { - printHelp(); - break; - } - - case 'quit': - case 'exit': { - await cleanup(); - return; - } - - default: { - if (command) { - console.log(`Unknown command: ${command}`); - } - break; - } - } - } catch (error) { - console.error(`Error executing command: ${error}`); - } finally { - isProcessingCommand = false; - } - - // Process another command after we've processed the this one - await commandLoop(); - }); -} - -async function elicitationLoop(): Promise { - while (true) { - // Wait until we have elicitations to process - await new Promise(resolve => { - if (elicitationQueue.length > 0) { - resolve(); - } else { - elicitationQueueSignal = resolve; - } - }); - - isProcessingElicitations = true; - abortCommand.abort(); // Abort the command loop if it's running - - // Process all queued elicitations - while (elicitationQueue.length > 0) { - const queued = elicitationQueue.shift()!; - console.log(`📤 Processing queued elicitation (${elicitationQueue.length} remaining)`); - - try { - const result = await handleElicitationRequest(queued.request); - queued.resolve(result); - } catch (error) { - queued.reject(error instanceof Error ? error : new Error(String(error))); - } - } - - console.log('✅ All queued elicitations processed. Resuming command loop...\n'); - isProcessingElicitations = false; - - // Reset the abort controller for the next command loop - abortCommand = new AbortController(); - - // Resume the command loop - if (elicitationsCompleteSignal) { - elicitationsCompleteSignal(); - elicitationsCompleteSignal = null; - } - } -} - -const ALLOWED_SCHEMES = new Set(['http:', 'https:']); - -async function openBrowser(url: string): Promise { - try { - const parsed = new URL(url); - if (!ALLOWED_SCHEMES.has(parsed.protocol)) { - console.error(`Refusing to open URL with unsupported scheme '${parsed.protocol}': ${url}`); - return; - } - } catch { - console.error(`Invalid URL: ${url}`); - return; - } - - try { - await open(url); - } catch { - console.log(`Please manually open: ${url}`); - } -} - -/** - * Enqueues an elicitation request and returns the result. - * - * This function is used so that our CLI (which can only handle one input request at a time) - * can handle elicitation requests and the command loop. - * - * @param request - The elicitation request to be handled - * @returns The elicitation result - */ -async function elicitationRequestHandler(request: ElicitRequest): Promise { - // If we are processing a command, handle this elicitation immediately - if (isProcessingCommand) { - console.log('📋 Processing elicitation immediately (during command execution)'); - return await handleElicitationRequest(request); - } - - // Otherwise, queue the request to be handled by the elicitation loop - console.log(`📥 Queueing elicitation request (queue size will be: ${elicitationQueue.length + 1})`); - - return new Promise((resolve, reject) => { - elicitationQueue.push({ - request, - resolve, - reject - }); - - // Signal the elicitation loop that there's work to do - if (elicitationQueueSignal) { - elicitationQueueSignal(); - elicitationQueueSignal = null; - } - }); -} - -/** - * Handles an elicitation request. - * - * This function is used to handle the elicitation request and return the result. - * - * @param request - The elicitation request to be handled - * @returns The elicitation result - */ -async function handleElicitationRequest(request: ElicitRequest): Promise { - const mode = request.params.mode; - console.log('\n🔔 Elicitation Request Received:'); - console.log(`Mode: ${mode}`); - - if (mode === 'url') { - return { - action: await handleURLElicitation(request.params as ElicitRequestURLParams) - }; - } else { - // Should not happen because the client declares its capabilities to the server, - // but being defensive is a good practice: - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${mode}`); - } -} - -/** - * Handles a URL elicitation by opening the URL in the browser. - * - * Note: This is a shared code for both request handlers and error handlers. - * As a result of sharing schema, there is no big forking of logic for the client. - * - * @param params - The URL elicitation request parameters - * @returns The action to take (accept, cancel, or decline) - */ -async function handleURLElicitation(params: ElicitRequestURLParams): Promise { - const url = params.url; - const elicitationId = params.elicitationId; - const message = params.message; - console.log(`🆔 Elicitation ID: ${elicitationId}`); // Print for illustration - - // Parse URL to show domain for security - let domain = 'unknown domain'; - try { - const parsedUrl = new URL(url); - domain = parsedUrl.hostname; - } catch { - console.error('Invalid URL provided by server'); - return 'decline'; - } - - // Example security warning to help prevent phishing attacks - console.log('\n⚠️ \u001B[33mSECURITY WARNING\u001B[0m ⚠️'); - console.log('\u001B[33mThe server is requesting you to open an external URL.\u001B[0m'); - console.log('\u001B[33mOnly proceed if you trust this server and understand why it needs this.\u001B[0m\n'); - console.log(`🌐 Target domain: \u001B[36m${domain}\u001B[0m`); - console.log(`🔗 Full URL: \u001B[36m${url}\u001B[0m`); - console.log(`\nℹ️ Server's reason:\n\n\u001B[36m${message}\u001B[0m\n`); - - // 1. Ask for user consent to open the URL - const consent = await new Promise(resolve => { - readline.question('\nDo you want to open this URL in your browser? (y/n): ', input => { - resolve(input.trim().toLowerCase()); - }); - }); - - // 2. If user did not consent, return appropriate result - if (consent === 'no' || consent === 'n') { - console.log('❌ URL navigation declined.'); - return 'decline'; - } else if (consent !== 'yes' && consent !== 'y') { - console.log('🚫 Invalid response. Cancelling elicitation.'); - return 'cancel'; - } - - // 3. Wait for completion notification in the background - const completionPromise = new Promise((resolve, reject) => { - const timeout = setTimeout( - () => { - pendingURLElicitations.delete(elicitationId); - console.log(`\u001B[31m❌ Elicitation ${elicitationId} timed out waiting for completion.\u001B[0m`); - reject(new Error('Elicitation completion timeout')); - }, - 5 * 60 * 1000 - ); // 5 minute timeout - - pendingURLElicitations.set(elicitationId, { - resolve: () => { - clearTimeout(timeout); - resolve(); - }, - reject, - timeout - }); - }); - - completionPromise.catch(error => { - console.error('Background completion wait failed:', error); - }); - - // 4. Open the URL in the browser - console.log(`\n🚀 Opening browser to: ${url}`); - await openBrowser(url); - - console.log('\n⏳ Waiting for you to complete the interaction in your browser...'); - console.log(' The server will send a notification once you complete the action.'); - - // 5. Acknowledge the user accepted the elicitation - return 'accept'; -} - -/** - * Example OAuth callback handler - in production, use a more robust approach - * for handling callbacks and storing tokens - */ -/** - * Starts a temporary HTTP server to receive the OAuth callback - */ -async function waitForOAuthCallback(): Promise { - return new Promise((resolve, reject) => { - const server = createServer((req, res) => { - // Ignore favicon requests - if (req.url === '/favicon.ico') { - res.writeHead(404); - res.end(); - return; - } - - console.log(`📥 Received callback: ${req.url}`); - const parsedUrl = new URL(req.url || '', 'http://localhost'); - const code = parsedUrl.searchParams.get('code'); - const error = parsedUrl.searchParams.get('error'); - - if (code) { - console.log(`✅ Authorization code received: ${code?.slice(0, 10)}...`); - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(` - - -

Authorization Successful!

-

This simulates successful authorization of the MCP client, which now has an access token for the MCP server.

-

This window will close automatically in 10 seconds.

- - - - `); - - resolve(code); - setTimeout(() => server.close(), 15_000); - } else if (error) { - console.log(`❌ Authorization error: ${error}`); - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end(` - - -

Authorization Failed

-

Error: ${error}

- - - `); - reject(new Error(`OAuth authorization failed: ${error}`)); - } else { - console.log(`❌ No authorization code or error in callback`); - res.writeHead(400); - res.end('Bad request'); - reject(new Error('No authorization code provided')); - } - }); - - server.listen(OAUTH_CALLBACK_PORT, () => { - console.log(`OAuth callback server started on http://localhost:${OAUTH_CALLBACK_PORT}`); - }); - }); -} - -/** - * Attempts to connect to the MCP server with OAuth authentication. - * Handles OAuth flow recursively if authorization is required. - */ -async function attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise { - console.log('🚢 Creating transport with OAuth provider...'); - const baseUrl = new URL(serverUrl); - transport = new StreamableHTTPClientTransport(baseUrl, { - sessionId: sessionId, - authProvider: oauthProvider - }); - console.log('🚢 Transport created'); - - try { - console.log('🔌 Attempting connection (this will trigger OAuth redirect if needed)...'); - await client!.connect(transport); - sessionId = transport.sessionId; - console.log('Transport created with session ID:', sessionId); - console.log('✅ Connected successfully'); - } catch (error) { - if (error instanceof UnauthorizedError) { - console.log('🔐 OAuth required - waiting for authorization...'); - const callbackPromise = waitForOAuthCallback(); - const authCode = await callbackPromise; - await transport.finishAuth(authCode); - console.log('🔐 Authorization code received:', authCode); - console.log('🔌 Reconnecting with authenticated transport...'); - // Recursively retry connection after OAuth completion - await attemptConnection(oauthProvider); - } else { - console.error('❌ Connection failed with non-auth error:', error); - throw error; - } - } -} - -async function connect(url?: string): Promise { - if (client) { - console.log('Already connected. Disconnect first.'); - return; - } - - if (url) { - serverUrl = url; - } - - console.log(`🔗 Attempting to connect to ${serverUrl}...`); - - // Create a new client with elicitation capability - console.log('👤 Creating MCP client...'); - client = new Client( - { - name: 'example-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: { - // Only URL elicitation is supported in this demo - // (see server/elicitationExample.ts for a demo of form mode elicitation) - url: {} - } - } - } - ); - console.log('👤 Client created'); - - // Set up elicitation request handler with proper validation - client.setRequestHandler('elicitation/create', elicitationRequestHandler); - - // Set up notification handler for elicitation completion - client.setNotificationHandler('notifications/elicitation/complete', notification => { - const { elicitationId } = notification.params; - const pending = pendingURLElicitations.get(elicitationId); - if (pending) { - clearTimeout(pending.timeout); - pendingURLElicitations.delete(elicitationId); - console.log(`\u001B[32m✅ Elicitation ${elicitationId} completed!\u001B[0m`); - pending.resolve(); - } else { - // Shouldn't happen - discard it! - console.warn(`Received completion notification for unknown elicitation: ${elicitationId}`); - } - }); - - try { - console.log('🔐 Starting OAuth flow...'); - await attemptConnection(oauthProvider!); - console.log('Connected to MCP server'); - - // Set up error handler after connection is established so we don't double log errors - client.onerror = error => { - console.error('\u001B[31mClient error:', error, '\u001B[0m'); - }; - } catch (error) { - console.error('Failed to connect:', error); - client = null; - transport = null; - return; - } -} - -async function disconnect(): Promise { - if (!client || !transport) { - console.log('Not connected.'); - return; - } - - try { - await transport.close(); - console.log('Disconnected from MCP server'); - client = null; - transport = null; - } catch (error) { - console.error('Error disconnecting:', error); - } -} - -async function terminateSession(): Promise { - if (!client || !transport) { - console.log('Not connected.'); - return; - } - - try { - console.log('Terminating session with ID:', transport.sessionId); - await transport.terminateSession(); - console.log('Session terminated successfully'); - - // Check if sessionId was cleared after termination - if (transport.sessionId) { - console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); - console.log('Session ID is still active:', transport.sessionId); - } else { - console.log('Session ID has been cleared'); - sessionId = undefined; - - // Also close the transport and clear client objects - await transport.close(); - console.log('Transport closed after session termination'); - client = null; - transport = null; - } - } catch (error) { - console.error('Error terminating session:', error); - } -} - -async function reconnect(): Promise { - if (client) { - await disconnect(); - } - await connect(); -} - -async function listTools(): Promise { - if (!client) { - console.log('Not connected to server.'); - return; - } - - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest); - - console.log('Available tools:'); - if (toolsResult.tools.length === 0) { - console.log(' No tools available'); - } else { - for (const tool of toolsResult.tools) { - console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); - } - } - } catch (error) { - console.log(`Tools not supported by this server (${error})`); - } -} - -async function callTool(name: string, args: Record): Promise { - if (!client) { - console.log('Not connected to server.'); - return; - } - - try { - console.log(`Calling tool '${name}' with args:`, args); - const result = await client.callTool({ name, arguments: args }); - - console.log('Tool result:'); - const resourceLinks: ResourceLink[] = []; - - for (const item of result.content) { - switch (item.type) { - case 'text': { - console.log(` ${item.text}`); - - break; - } - case 'resource_link': { - const resourceLink = item as ResourceLink; - resourceLinks.push(resourceLink); - console.log(` 📁 Resource Link: ${resourceLink.name}`); - console.log(` URI: ${resourceLink.uri}`); - if (resourceLink.mimeType) { - console.log(` Type: ${resourceLink.mimeType}`); - } - if (resourceLink.description) { - console.log(` Description: ${resourceLink.description}`); - } - - break; - } - case 'resource': { - console.log(` [Embedded Resource: ${item.resource.uri}]`); - - break; - } - case 'image': { - console.log(` [Image: ${item.mimeType}]`); - - break; - } - case 'audio': { - console.log(` [Audio: ${item.mimeType}]`); - - break; - } - default: { - console.log(` [Unknown content type]:`, item); - } - } - } - - // Offer to read resource links - if (resourceLinks.length > 0) { - console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`); - } - } catch (error) { - if (error instanceof UrlElicitationRequiredError) { - console.log('\n🔔 Elicitation Required Error Received:'); - console.log(`Message: ${error.message}`); - for (const e of error.elicitations) { - await handleURLElicitation(e); // For the error handler, we discard the action result because we don't respond to an error response - } - return; - } - console.log(`Error calling tool ${name}: ${error}`); - } -} - -async function cleanup(): Promise { - if (client && transport) { - try { - // First try to terminate the session gracefully - if (transport.sessionId) { - try { - console.log('Terminating session before exit...'); - await transport.terminateSession(); - console.log('Session terminated successfully'); - } catch (error) { - console.error('Error terminating session:', error); - } - } - - // Then close the transport - await transport.close(); - } catch (error) { - console.error('Error closing transport:', error); - } - } - - process.stdin.setRawMode(false); - readline.close(); - console.log('\nGoodbye!'); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(0); -} - -async function callPaymentConfirmTool(): Promise { - console.log('Calling payment-confirm tool...'); - await callTool('payment-confirm', { cartId: 'cart_123' }); -} - -async function callThirdPartyAuthTool(): Promise { - console.log('Calling third-party-auth tool...'); - await callTool('third-party-auth', { param1: 'test' }); -} - -// Set up raw mode for keyboard input to capture Escape key -process.stdin.setRawMode(true); -process.stdin.on('data', async data => { - // Check for Escape key (27) - if (data.length === 1 && data[0] === 27) { - console.log('\nESC key pressed. Disconnecting from server...'); - - // Abort current operation and disconnect from server - if (client && transport) { - await disconnect(); - console.log('Disconnected. Press Enter to continue.'); - } else { - console.log('Not connected to server.'); - } - - // Re-display the prompt - process.stdout.write('> '); - } -}); - -// Handle Ctrl+C -process.on('SIGINT', async () => { - console.log('\nReceived SIGINT. Cleaning up...'); - await cleanup(); -}); - -// Start the interactive client -try { - await main(); -} catch (error) { - console.error('Error running MCP client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/multipleClientsParallel.ts b/examples/client/src/multipleClientsParallel.ts deleted file mode 100644 index 6543bae020..0000000000 --- a/examples/client/src/multipleClientsParallel.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { CallToolResult } from '@modelcontextprotocol/client'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -/** - * Multiple Clients MCP Example - * - * This client demonstrates how to: - * 1. Create multiple MCP clients in parallel - * 2. Each client calls a single tool - * 3. Track notifications from each client independently - */ - -// Command line args processing -const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3000/mcp'; - -interface ClientConfig { - id: string; - name: string; - toolName: string; - toolArguments: Record; -} - -async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: CallToolResult }> { - console.log(`[${config.id}] Creating client: ${config.name}`); - - const client = new Client({ - name: config.name, - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); - - // Set up client-specific error handler - client.onerror = error => { - console.error(`[${config.id}] Client error:`, error); - }; - - // Set up client-specific notification handler - client.setNotificationHandler('notifications/message', notification => { - console.log(`[${config.id}] Notification: ${notification.params.data}`); - }); - - try { - // Connect to the server - await client.connect(transport); - console.log(`[${config.id}] Connected to MCP server`); - - // Call the specified tool - console.log(`[${config.id}] Calling tool: ${config.toolName}`); - const result = await client.callTool({ - name: config.toolName, - arguments: { - ...config.toolArguments, - // Add client ID to arguments for identification in notifications - caller: config.id - } - }); - console.log(`[${config.id}] Tool call completed`); - - // Keep the connection open for a bit to receive notifications - await new Promise(resolve => setTimeout(resolve, 5000)); - - // Disconnect - await transport.close(); - console.log(`[${config.id}] Disconnected from MCP server`); - - return { id: config.id, result }; - } catch (error) { - console.error(`[${config.id}] Error:`, error); - throw error; - } -} - -async function main(): Promise { - console.log('MCP Multiple Clients Example'); - console.log('============================'); - console.log(`Server URL: ${serverUrl}`); - console.log(''); - - try { - // Define client configurations - const clientConfigs: ClientConfig[] = [ - { - id: 'client1', - name: 'basic-client-1', - toolName: 'start-notification-stream', - toolArguments: { - interval: 3, // 1 second between notifications - count: 5 // Send 5 notifications - } - }, - { - id: 'client2', - name: 'basic-client-2', - toolName: 'start-notification-stream', - toolArguments: { - interval: 2, // 2 seconds between notifications - count: 3 // Send 3 notifications - } - }, - { - id: 'client3', - name: 'basic-client-3', - toolName: 'start-notification-stream', - toolArguments: { - interval: 1, // 0.5 second between notifications - count: 8 // Send 8 notifications - } - } - ]; - - // Start all clients in parallel - console.log(`Starting ${clientConfigs.length} clients in parallel...`); - console.log(''); - - const clientPromises = clientConfigs.map(config => createAndRunClient(config)); - const results = await Promise.all(clientPromises); - - // Display results from all clients - console.log('\n=== Final Results ==='); - for (const { id, result } of results) { - console.log(`\n[${id}] Tool result:`); - if (Array.isArray(result.content)) { - for (const item of result.content) { - if (item.type === 'text' && item.text) { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - } - } else { - console.log(` Unexpected result format:`, result); - } - } - - console.log('\n=== All clients completed successfully ==='); - } catch (error) { - console.error('Error running multiple clients:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } -} - -// Start the example -try { - await main(); -} catch (error) { - console.error('Error running multiple clients:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/parallelToolCallsClient.ts b/examples/client/src/parallelToolCallsClient.ts deleted file mode 100644 index 5b16cc9cc8..0000000000 --- a/examples/client/src/parallelToolCallsClient.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { CallToolResult, ListToolsRequest } from '@modelcontextprotocol/client'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -/** - * Parallel Tool Calls MCP Client - * - * This client demonstrates how to: - * 1. Start multiple tool calls in parallel - * 2. Track notifications from each tool call using a caller parameter - */ - -// Command line args processing -const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3000/mcp'; - -async function main(): Promise { - console.log('MCP Parallel Tool Calls Client'); - console.log('=============================='); - console.log(`Connecting to server at: ${serverUrl}`); - - let client: Client; - let transport: StreamableHTTPClientTransport; - - try { - // Create client with streamable HTTP transport - client = new Client({ - name: 'parallel-tool-calls-client', - version: '1.0.0' - }); - - client.onerror = error => { - console.error('Client error:', error); - }; - - // Connect to the server - transport = new StreamableHTTPClientTransport(new URL(serverUrl)); - await client.connect(transport); - console.log('Successfully connected to MCP server'); - - // Set up notification handler with caller identification - client.setNotificationHandler('notifications/message', notification => { - console.log(`Notification: ${notification.params.data}`); - }); - - console.log('List tools'); - const toolsRequest = await listTools(client); - console.log('Tools:', toolsRequest); - - // 2. Start multiple notification tools in parallel - console.log('\n=== Starting Multiple Notification Streams in Parallel ==='); - const toolResults = await startParallelNotificationTools(client); - - // Log the results from each tool call - for (const [caller, result] of Object.entries(toolResults)) { - console.log(`\n=== Tool result for ${caller} ===`); - for (const item of result.content) { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - } - } - - // 3. Wait for all notifications (10 seconds) - console.log('\n=== Waiting for all notifications ==='); - await new Promise(resolve => setTimeout(resolve, 10_000)); - - // 4. Disconnect - console.log('\n=== Disconnecting ==='); - await transport.close(); - console.log('Disconnected from MCP server'); - } catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } -} - -/** - * List available tools on the server - */ -async function listTools(client: Client): Promise { - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest); - - console.log('Available tools:'); - if (toolsResult.tools.length === 0) { - console.log(' No tools available'); - } else { - for (const tool of toolsResult.tools) { - console.log(` - ${tool.name}: ${tool.description}`); - } - } - } catch (error) { - console.log(`Tools not supported by this server: ${error}`); - } -} - -/** - * Start multiple notification tools in parallel with different configurations - * Each tool call includes a caller parameter to identify its notifications - */ -async function startParallelNotificationTools(client: Client): Promise> { - try { - // Define multiple tool calls with different configurations - const toolCalls = [ - { - caller: 'fast-notifier', - args: { - interval: 2, // 0.5 second between notifications - count: 10, // Send 10 notifications - caller: 'fast-notifier' // Identify this tool call - } - }, - { - caller: 'slow-notifier', - args: { - interval: 5, // 2 seconds between notifications - count: 5, // Send 5 notifications - caller: 'slow-notifier' // Identify this tool call - } - }, - { - caller: 'burst-notifier', - args: { - interval: 1, // 0.1 second between notifications - count: 3, // Send just 3 notifications - caller: 'burst-notifier' // Identify this tool call - } - } - ]; - - console.log(`Starting ${toolCalls.length} notification tools in parallel...`); - - // Start all tool calls in parallel - const toolPromises = toolCalls.map(({ caller, args }) => { - console.log(`Starting tool call for ${caller}...`); - return client - .callTool({ name: 'start-notification-stream', arguments: args }) - .then(result => ({ caller, result })) - .catch(error => { - console.error(`Error in tool call for ${caller}:`, error); - throw error; - }); - }); - - // Wait for all tool calls to complete - const results = await Promise.all(toolPromises); - - // Organize results by caller - const resultsByTool: Record = {}; - for (const { caller, result } of results) { - resultsByTool[caller] = result; - } - - return resultsByTool; - } catch (error) { - console.error(`Error starting parallel notification tools:`, error); - throw error; - } -} - -try { - // Run the client - await main(); -} catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/simpleClientCredentials.ts b/examples/client/src/simpleClientCredentials.ts deleted file mode 100644 index 58f17e312a..0000000000 --- a/examples/client/src/simpleClientCredentials.ts +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node - -/** - * Example demonstrating client_credentials grant for machine-to-machine authentication. - * - * Supports two authentication methods based on environment variables: - * - * 1. client_secret_basic (default): - * MCP_CLIENT_ID - OAuth client ID (required) - * MCP_CLIENT_SECRET - OAuth client secret (required) - * - * 2. private_key_jwt (when MCP_CLIENT_PRIVATE_KEY_PEM is set): - * MCP_CLIENT_ID - OAuth client ID (required) - * MCP_CLIENT_PRIVATE_KEY_PEM - PEM-encoded private key for JWT signing (required) - * MCP_CLIENT_ALGORITHM - Signing algorithm (default: RS256) - * - * Common: - * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) - */ - -import type { OAuthClientProvider } from '@modelcontextprotocol/client'; -import { Client, ClientCredentialsProvider, PrivateKeyJwtProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; - -function createProvider(): OAuthClientProvider { - const clientId = process.env.MCP_CLIENT_ID; - if (!clientId) { - console.error('MCP_CLIENT_ID environment variable is required'); - process.exit(1); - } - - // If private key is provided, use private_key_jwt authentication - const privateKeyPem = process.env.MCP_CLIENT_PRIVATE_KEY_PEM; - if (privateKeyPem) { - const algorithm = process.env.MCP_CLIENT_ALGORITHM || 'RS256'; - console.log('Using private_key_jwt authentication'); - return new PrivateKeyJwtProvider({ - clientId, - privateKey: privateKeyPem, - algorithm - }); - } - - // Otherwise, use client_secret_basic authentication - const clientSecret = process.env.MCP_CLIENT_SECRET; - if (!clientSecret) { - console.error('MCP_CLIENT_SECRET or MCP_CLIENT_PRIVATE_KEY_PEM environment variable is required'); - process.exit(1); - } - - console.log('Using client_secret_basic authentication'); - return new ClientCredentialsProvider({ - clientId, - clientSecret - }); -} - -async function main() { - const provider = createProvider(); - - const client = new Client({ name: 'client-credentials-example', version: '1.0.0' }, { capabilities: {} }); - - const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { - authProvider: provider - }); - - await client.connect(transport); - console.log('Connected successfully.'); - - const tools = await client.listTools(); - console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)'); - - await transport.close(); -} - -try { - await main(); -} catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/ssePollingClient.ts b/examples/client/src/ssePollingClient.ts deleted file mode 100644 index 2d1115e72a..0000000000 --- a/examples/client/src/ssePollingClient.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * SSE Polling Example Client (SEP-1699) - * - * This example demonstrates client-side behavior during server-initiated - * SSE stream disconnection and automatic reconnection. - * - * Key features demonstrated: - * - Automatic reconnection when server closes SSE stream - * - Event replay via Last-Event-ID header - * - Resumption token tracking via onresumptiontoken callback - * - * Run with: pnpm tsx src/ssePollingClient.ts - * Requires: ssePollingExample.ts server running on port 3001 - */ -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -const SERVER_URL = 'http://localhost:3001/mcp'; - -async function main(): Promise { - console.log('SSE Polling Example Client'); - console.log('=========================='); - console.log(`Connecting to ${SERVER_URL}...`); - console.log(''); - - // Create transport with reconnection options - const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), { - // Use default reconnection options - SDK handles automatic reconnection - }); - - // Track the last event ID for debugging - let lastEventId: string | undefined; - - // Set up transport error handler to observe disconnections - // Filter out expected errors from SSE reconnection - transport.onerror = error => { - // Skip abort errors during intentional close - if (error.message.includes('AbortError')) return; - // Show SSE disconnect (expected when server closes stream) - if (error.message.includes('Unexpected end of JSON')) { - console.log('[Transport] SSE stream disconnected - client will auto-reconnect'); - return; - } - console.log(`[Transport] Error: ${error.message}`); - }; - - // Set up transport close handler - transport.onclose = () => { - console.log('[Transport] Connection closed'); - }; - - // Create and connect client - const client = new Client({ - name: 'sse-polling-client', - version: '1.0.0' - }); - - // Set up notification handler to receive progress updates - client.setNotificationHandler('notifications/message', notification => { - const data = notification.params.data; - console.log(`[Notification] ${data}`); - }); - - try { - await client.connect(transport); - console.log('[Client] Connected successfully'); - console.log(''); - - // Call the long-operation tool - console.log('[Client] Calling long-operation tool...'); - console.log('[Client] Server will disconnect mid-operation to demonstrate polling'); - console.log(''); - - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'long-operation', - arguments: {} - } - }, - { - // Track resumption tokens for debugging - onresumptiontoken: token => { - lastEventId = token; - console.log(`[Event ID] ${token}`); - } - } - ); - - console.log(''); - console.log('[Client] Tool completed!'); - console.log(`[Result] ${JSON.stringify(result.content, null, 2)}`); - console.log(''); - console.log(`[Debug] Final event ID: ${lastEventId}`); - } catch (error) { - console.error('[Error]', error); - } finally { - await transport.close(); - console.log('[Client] Disconnected'); - } -} - -try { - await main(); -} catch (error) { - console.error('Error running MCP client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/streamableHttpWithSseFallbackClient.ts b/examples/client/src/streamableHttpWithSseFallbackClient.ts deleted file mode 100644 index 0925f8dd0b..0000000000 --- a/examples/client/src/streamableHttpWithSseFallbackClient.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { ListToolsRequest } from '@modelcontextprotocol/client'; -import { Client, SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -/** - * Simplified Backwards Compatible MCP Client - * - * This client demonstrates backward compatibility with both: - * 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26) - * 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05) - * - * Following the MCP specification for backwards compatibility: - * - Attempts to POST an initialize request to the server URL first (modern transport) - * - If that fails with 4xx status, falls back to GET request for SSE stream (older transport) - */ - -// Command line args processing -const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3000/mcp'; - -async function main(): Promise { - console.log('MCP Backwards Compatible Client'); - console.log('==============================='); - console.log(`Connecting to server at: ${serverUrl}`); - - let client: Client; - let transport: StreamableHTTPClientTransport | SSEClientTransport; - - try { - // Try connecting with automatic transport detection - const connection = await connectWithBackwardsCompatibility(serverUrl); - client = connection.client; - transport = connection.transport; - - // Set up notification handler - client.setNotificationHandler('notifications/message', notification => { - console.log(`Notification: ${notification.params.level} - ${notification.params.data}`); - }); - - // DEMO WORKFLOW: - // 1. List available tools - console.log('\n=== Listing Available Tools ==='); - await listTools(client); - - // 2. Call the notification tool - console.log('\n=== Starting Notification Stream ==='); - await startNotificationTool(client); - - // 3. Wait for all notifications (5 seconds) - console.log('\n=== Waiting for all notifications ==='); - await new Promise(resolve => setTimeout(resolve, 5000)); - - // 4. Disconnect - console.log('\n=== Disconnecting ==='); - await transport.close(); - console.log('Disconnected from MCP server'); - } catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } -} - -/** - * Connect to an MCP server with backwards compatibility - * Following the spec for client backward compatibility - */ -async function connectWithBackwardsCompatibility(url: string): Promise<{ - client: Client; - transport: StreamableHTTPClientTransport | SSEClientTransport; - transportType: 'streamable-http' | 'sse'; -}> { - console.log('1. Trying Streamable HTTP transport first...'); - - // Step 1: Try Streamable HTTP transport first - const client = new Client({ - name: 'backwards-compatible-client', - version: '1.0.0' - }); - - client.onerror = error => { - console.error('Client error:', error); - }; - const baseUrl = new URL(url); - - try { - // Create modern transport - const streamableTransport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(streamableTransport); - - console.log('Successfully connected using modern Streamable HTTP transport.'); - return { - client, - transport: streamableTransport, - transportType: 'streamable-http' - }; - } catch (error) { - // Step 2: If transport fails, try the older SSE transport - console.log(`StreamableHttp transport connection failed: ${error}`); - console.log('2. Falling back to deprecated HTTP+SSE transport...'); - - try { - // Create SSE transport pointing to /sse endpoint - const sseTransport = new SSEClientTransport(baseUrl); - const sseClient = new Client({ - name: 'backwards-compatible-client', - version: '1.0.0' - }); - await sseClient.connect(sseTransport); - - console.log('Successfully connected using deprecated HTTP+SSE transport.'); - return { - client: sseClient, - transport: sseTransport, - transportType: 'sse' - }; - } catch (sseError) { - console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`); - throw new Error('Could not connect to server with any available transport'); - } - } -} - -/** - * List available tools on the server - */ -async function listTools(client: Client): Promise { - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest); - - console.log('Available tools:'); - if (toolsResult.tools.length === 0) { - console.log(' No tools available'); - } else { - for (const tool of toolsResult.tools) { - console.log(` - ${tool.name}: ${tool.description}`); - } - } - } catch (error) { - console.log(`Tools not supported by this server: ${error}`); - } -} - -/** - * Start a notification stream by calling the notification tool - */ -async function startNotificationTool(client: Client): Promise { - try { - console.log('Calling notification tool...'); - const result = await client.callTool({ - name: 'start-notification-stream', - arguments: { - interval: 1000, // 1 second between notifications - count: 5 // Send 5 notifications - } - }); - - console.log('Tool result:'); - for (const item of result.content) { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - } - } catch (error) { - console.log(`Error calling notification tool: ${error}`); - } -} - -// Start the client -try { - await main(); -} catch (error) { - console.error('Error running MCP client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/tsconfig.json b/examples/client/tsconfig.json deleted file mode 100644 index 5c1f7fc764..0000000000 --- a/examples/client/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "paths": { - "*": ["./*"], - "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], - "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], - "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], - "@modelcontextprotocol/core": [ - "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/index.ts" - ], - "@modelcontextprotocol/core/public": [ - "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" - ], - "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], - "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], - "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"] - } - } -} diff --git a/examples/client/tsdown.config.ts b/examples/client/tsdown.config.ts deleted file mode 100644 index efc4299d35..0000000000 --- a/examples/client/tsdown.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - // 1. Entry Points - // Directly matches package.json include/exclude globs - entry: ['src/**/*.ts'], - - // 2. Output Configuration - format: ['esm'], - outDir: 'dist', - clean: true, // Recommended: Cleans 'dist' before building - sourcemap: true, - - // 3. Platform & Target - target: 'esnext', - platform: 'node', - shims: true, // Polyfills common Node.js shims (__dirname, etc.) - - // 4. Type Definitions - // Bundles d.ts files into a single output - dts: false, - // 5. Vendoring Strategy - Bundle the code for this specific package into the output, - // but treat all other dependencies as external (require/import). - noExternal: ['@modelcontextprotocol/examples-shared'] -}); diff --git a/examples/client/vitest.config.js b/examples/client/vitest.config.js deleted file mode 100644 index 496fca3200..0000000000 --- a/examples/client/vitest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; diff --git a/examples/custom-methods/README.md b/examples/custom-methods/README.md new file mode 100644 index 0000000000..6f778ebee9 --- /dev/null +++ b/examples/custom-methods/README.md @@ -0,0 +1,8 @@ +# custom-methods + +Bidirectional custom (non-spec) JSON-RPC methods: the server handles a vendor-prefixed `acme/search` request via `server.setRequestHandler` and emits `acme/searchProgress` notifications via `ctx.mcpReq.notify`; the client sends the typed request via +`client.request(method, schema)` and receives the typed notifications via `client.setNotificationHandler('acme/searchProgress', { params })`. + +```bash +pnpm tsx examples/custom-methods/client.ts +``` diff --git a/examples/custom-methods/client.ts b/examples/custom-methods/client.ts new file mode 100644 index 0000000000..c92a4080a3 --- /dev/null +++ b/examples/custom-methods/client.ts @@ -0,0 +1,32 @@ +/** + * Custom (non-spec) method example: a client that sends `acme/search` and + * listens for `acme/searchProgress` notifications. + * + * The client spawns the sibling server straight from source over stdio (no + * build step), or connects to a running endpoint under `--http `. + */ +import { z } from 'zod/v4'; + +import { check, connectFromArgs, runClient } from '../harness.js'; + +const SearchResult = z.object({ items: z.array(z.string()) }); +const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); + +runClient('custom-methods', async () => { + // Vendor-prefixed methods route through both serving entries unchanged: a + // 2025 client sends the bare JSON-RPC request, a 2026-07-28 client sends it + // with the per-request envelope; `setRequestHandler` receives either. + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname); + + const stages: string[] = []; + client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { + stages.push(params.stage); + }); + + const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); + check.deepEqual(result.items, ['mcp-0', 'mcp-1', 'mcp-2']); + check.deepEqual(stages, ['start', 'done']); + + await client.close(); +}); diff --git a/examples/custom-methods/package.json b/examples/custom-methods/package.json new file mode 100644 index 0000000000..2ceb27e0db --- /dev/null +++ b/examples/custom-methods/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mcp-examples/custom-methods", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "dual", + "//": "Vendor-prefixed methods route through both serving entries unchanged: a 2025 client sends the bare JSON-RPC request, a 2026-07-28 client sends it with the per-request envelope; setRequestHandler receives either." + } +} diff --git a/examples/custom-methods/server.ts b/examples/custom-methods/server.ts new file mode 100644 index 0000000000..7df95fe999 --- /dev/null +++ b/examples/custom-methods/server.ts @@ -0,0 +1,29 @@ +/** + * Custom (non-spec) method example: a server that handles a vendor-prefixed + * `acme/search` request and emits `acme/searchProgress` notifications. + * + * One binary, either transport (selected by the shared scaffold from argv). + */ +import { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) }); +const SearchResult = z.object({ items: z.array(z.string()) }); + +function buildServer(): McpServer { + const mcp = new McpServer({ name: 'acme-search', version: '0.0.0' }); + + mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); + const items = Array.from({ length: params.limit }, (_, i) => `${params.query}-${i}`); + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } }); + return { items }; + }); + + return mcp; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/custom-version/README.md b/examples/custom-version/README.md new file mode 100644 index 0000000000..1bd05975ad --- /dev/null +++ b/examples/custom-version/README.md @@ -0,0 +1,7 @@ +# custom-version + +`ServerOptions.supportedProtocolVersions` — declare support for protocol versions not yet in the SDK. The first version in the list is the fallback when a client requests an unsupported one. + +```bash +pnpm tsx examples/custom-version/client.ts +``` diff --git a/examples/custom-version/client.ts b/examples/custom-version/client.ts new file mode 100644 index 0000000000..44ee20349a --- /dev/null +++ b/examples/custom-version/client.ts @@ -0,0 +1,22 @@ +/** + * Initializes with a protocol version the server lists in + * `supportedProtocolVersions` (and one it does not, to assert the fallback). + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('custom-version', async () => { + // A plain (2025-handshake) client; the server supports the SDK's stock + // 2025 version so this negotiates that. + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined }); + + // The server should advertise its supportedProtocolVersions in its + // tool's text payload. + const result = await client.callTool({ name: 'get-protocol-info' }); + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '{}'; + const info = JSON.parse(text) as { supportedVersions: string[] }; + check.ok(info.supportedVersions.includes('2026-01-01')); + check.ok(info.supportedVersions.length > 1); + + await client.close(); +}); diff --git a/examples/custom-version/package.json b/examples/custom-version/package.json new file mode 100644 index 0000000000..60bb5ae272 --- /dev/null +++ b/examples/custom-version/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mcp-examples/custom-version", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "legacy", + "//": "supportedProtocolVersions / version negotiation is the 2025 initialize handshake; the modern era is its own negotiation story (../dual-era/)." + } +} diff --git a/examples/custom-version/server.ts b/examples/custom-version/server.ts new file mode 100644 index 0000000000..6eda353c69 --- /dev/null +++ b/examples/custom-version/server.ts @@ -0,0 +1,27 @@ +/** + * `supportedProtocolVersions`: support a protocol version not yet in the SDK. + * The first version in the list is the fallback when the client requests an + * unsupported one. One binary, either transport. + */ +import { McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; + +import { runServerFromArgs } from '../harness.js'; + +// Add support for a newer protocol version (first in list is fallback). +const CUSTOM_VERSIONS = ['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS]; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'custom-protocol-server', version: '1.0.0' }, + { supportedProtocolVersions: CUSTOM_VERSIONS, capabilities: { tools: {} } } + ); + + server.registerTool('get-protocol-info', { description: 'Returns protocol version configuration' }, async () => ({ + content: [{ type: 'text', text: JSON.stringify({ supportedVersions: CUSTOM_VERSIONS }) }] + })); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/dual-era/README.md b/examples/dual-era/README.md new file mode 100644 index 0000000000..0da44000b2 --- /dev/null +++ b/examples/dual-era/README.md @@ -0,0 +1,12 @@ +# dual-era + +One server factory, both protocol eras (2025 `initialize` and 2026-07-28 per-request envelope), both transports (stdio and Streamable HTTP). The client connects once as a plain 2025 client and once with `versionNegotiation: { mode: 'auto' }`; the same `greet` tool answers both +and reports which era served the call. + +This is the recommended **first** example to read if you are migrating an existing server to the 2026 era: the entry (`serveStdio` / `createMcpHandler`) owns the era decision, the factory is era-agnostic. + +```bash +pnpm tsx examples/dual-era/client.ts # stdio +pnpm tsx examples/dual-era/server.ts --http --port 3000 # term 1 +pnpm tsx examples/dual-era/client.ts --http http://127.0.0.1:3000/ # term 2 +``` diff --git a/examples/dual-era/client.ts b/examples/dual-era/client.ts new file mode 100644 index 0000000000..69d47289b3 --- /dev/null +++ b/examples/dual-era/client.ts @@ -0,0 +1,37 @@ +/** + * Drives the dual-era server (`./server.ts`) over the selected transport with + * BOTH kinds of client: + * + * 1. a plain 2025 client — the `initialize` handshake, served exactly as + * today (the server reports `era === 'legacy'`); + * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the + * `server/discover` probe negotiates the 2026-07-28 revision (no + * `initialize` is ever sent) and the SDK attaches the per-request `_meta` + * envelope itself (the server reports `era === 'modern'`). + * + * Asserts both legs and exits 0 — used as a self-verifying e2e by + * `scripts/run-examples.ts` over stdio AND http. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('dual-era', async () => { + // --- leg 1: plain 2025 client (initialize handshake) --- + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const legacy = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined }); + const legacyTools = await legacy.listTools(); + check.ok(legacyTools.tools.some(t => t.name === 'greet')); + const legacyGreet = await legacy.callTool({ name: 'greet', arguments: { name: '2025 client' } }); + const legacyText = legacyGreet.content?.[0]?.type === 'text' ? legacyGreet.content[0].text : ''; + check.match(legacyText, /Hello, 2025 client! \(served on the legacy protocol era\)/); + await legacy.close(); + + // --- leg 2: 2026-capable client (server/discover negotiation) --- + const modern = await connectFromArgs(import.meta.dirname); + check.equal(modern.getNegotiatedProtocolVersion(), '2026-07-28'); + const modernGreet = await modern.callTool({ name: 'greet', arguments: { name: '2026 client' } }); + const modernText = modernGreet.content?.[0]?.type === 'text' ? modernGreet.content[0].text : ''; + check.match(modernText, /Hello, 2026 client! \(served on the modern protocol era\)/); + await modern.close(); + + console.log('both eras served by the same factory over the same transport.'); +}); diff --git a/examples/dual-era/package.json b/examples/dual-era/package.json new file mode 100644 index 0000000000..9851ad1369 --- /dev/null +++ b/examples/dual-era/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mcp-examples/dual-era", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "The story body drives BOTH eras itself (legacy via versionNegotiation: undefined, modern via the harness default); pinned so the harness runs it once per transport." + } +} diff --git a/examples/dual-era/server.ts b/examples/dual-era/server.ts new file mode 100644 index 0000000000..94158c35f0 --- /dev/null +++ b/examples/dual-era/server.ts @@ -0,0 +1,44 @@ +/** + * Dual-era serving from one factory, both transports. + * + * The same factory backs both protocol eras: a 2025-era client connects with + * the `initialize` handshake; a 2026-capable client + * (`versionNegotiation: { mode: 'auto' }`) probes with `server/discover`, + * negotiates the 2026-07-28 revision, and the SDK attaches the per-request + * `_meta` envelope to every outgoing request itself. Tools are defined once + * and served identically to either kind of client. + * + * One binary, either transport (selected by the shared `runServerFromArgs` + * scaffold from argv): stdio by default (`serveStdio(factory)`), or HTTP + * under `--http --port ` (`createMcpHandler(factory)` on its default + * posture — modern served per request, 2025-era traffic served stateless from + * the same factory). + */ +import type { CallToolResult, McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +const buildServer = (ctx: McpRequestContext) => { + const server = new McpServer( + { name: 'dual-era-server', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'A small dual-era demo server.' } + ); + + server.registerTool( + 'greet', + { + description: 'Greets the caller and reports which protocol era served the request', + inputSchema: z.object({ name: z.string().describe('Name to greet') }) + }, + async ({ name }): Promise => ({ + content: [{ type: 'text', text: `Hello, ${name}! (served on the ${ctx.era} protocol era)` }] + }) + ); + + return server; +}; + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/elicitation/README.md b/examples/elicitation/README.md new file mode 100644 index 0000000000..98d2c362a4 --- /dev/null +++ b/examples/elicitation/README.md @@ -0,0 +1,20 @@ +# elicitation + +Server requests user input. One factory, both protocol eras: elicitation works on both eras with different APIs — push-style on 2025, `inputRequired` on 2026; the protocol carries it differently but the user experience is the same. + +| Mode | 2025-era (`--legacy`, push-style) | 2026-07-28 (multi-round-trip) | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **form** (`register_user`) | `await ctx.mcpReq.elicitInput({ mode: 'form', requestedSchema })` — the server pushes an `elicitation/create` request and awaits the answer in-line | `return inputRequired({ inputRequests: { form: inputRequired.elicit(...) } })` — the client collects the form and retries the same handler with the response attached | +| **url** (`link_account`) | `await ctx.mcpReq.elicitInput({ mode: 'url', url, elicitationId })` + `createElicitationCompletionNotifier(elicitationId)` for the out-of-band `notifications/elicitation/complete` | `return inputRequired({ inputRequests: { auth: inputRequired.elicitUrl(...) } })` — no `elicitationId` / complete notification on this era | +| **url, throw** (`confirm_payment`) | `throw new UrlElicitationRequiredError([...])` — the wire `-32042`; the client catches the typed error and reads `.elicitations` | n/a — a throw on this era fails loudly with a steer to `inputRequired.elicitUrl(...)` | + +`plan_trip` chains **two** form elicitations inside one tool call (destination → dates for that destination): two sequential `ctx.mcpReq.elicitInput` pushes on 2025, two `inputRequired` rounds with `requestState` carry-over on 2026. The `register_user` form schema includes an +`enumNames` field (display labels for the `plan` enum). For the secure `requestState` round-trip pattern see [`../mrtr/`](../mrtr/README.md). + +Runs the full transport × era matrix: the harness's `--http` arm hosts 2025 traffic on a sessionful `NodeStreamableHTTPServerTransport` (the same `isLegacyRequest` composition `../legacy-routing/` shows by hand), so push server→client requests reach the client over either +transport. + +```bash +pnpm --filter @mcp-examples/elicitation client # 2026-07-28 (inputRequired) +pnpm --filter @mcp-examples/elicitation client -- --legacy # 2025 (push-style) +``` diff --git a/examples/elicitation/client.ts b/examples/elicitation/client.ts new file mode 100644 index 0000000000..03bef8614b --- /dev/null +++ b/examples/elicitation/client.ts @@ -0,0 +1,92 @@ +/** + * Auto-answers form and URL elicitations on either protocol era and asserts + * the tool's text reflects the elicitation outcome. + * + * On the 2025-era leg (`--legacy`) the server pushes `elicitation/create` + * requests and a `notifications/elicitation/complete` notification, and the + * `confirm_payment` tool throws a typed `UrlElicitationRequiredError` the + * client catches. On the 2026-07-28 leg the same `elicitation/create` + * handler is dispatched by the auto-fulfilment engine for the embedded + * `inputRequired` requests; there is no throw-style or complete-notification + * surface on that era, so those assertions are gated to the legacy leg. + */ +import type { ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/client'; +import { UrlElicitationRequiredError } from '@modelcontextprotocol/client'; + +import { check, connectFromArgs, eraLeg, runClient } from '../harness.js'; + +runClient('elicitation', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {}, url: {} } } }); + + // URL-mode requests on the 2025 era carry an `elicitationId`; the client + // waits for `notifications/elicitation/complete` with that id (the + // out-of-band "the user finished the URL flow" signal) before answering. + const completed = new Map void>(); + client.setNotificationHandler('notifications/elicitation/complete', notification => { + const id = (notification.params as { elicitationId: string }).elicitationId; + completed.get(id)?.(); + }); + + let formAction: 'accept' | 'decline' = 'accept'; + client.setRequestHandler('elicitation/create', async (request): Promise => { + const params = request.params as { mode?: 'form' | 'url'; requestedSchema?: { properties?: Record } } & Partial< + Pick + >; + if (params.mode === 'url') { + // A real client would open `params.url` in a browser here. On the + // 2025 era it then waits for the matching complete notification + // before resolving; on the 2026 era there is no elicitationId and + // the client answers as soon as the user finishes. + check.ok(params.url?.startsWith('https://example.com/')); + if (params.elicitationId) { + await new Promise(resolve => completed.set(params.elicitationId as string, resolve)); + } + return { action: 'accept' }; + } + if (params.requestedSchema?.properties?.['destination']) { + return { action: 'accept', content: { destination: 'Tokyo' } }; + } + if (params.requestedSchema?.properties?.['departure']) { + return { action: 'accept', content: { departure: '2026-09-01', nights: 7 } }; + } + check.ok(params.requestedSchema?.properties?.['username'], 'elicitation should carry the requestedSchema'); + if (formAction === 'decline') return { action: 'decline' }; + return { action: 'accept', content: { username: 'alice', email: 'alice@example.com', plan: 'pro' } }; + }); + + // ---- Form mode (accept then decline) ------------------------------------- + const accepted = await client.callTool({ name: 'register_user' }); + check.match( + accepted.content?.[0]?.type === 'text' ? accepted.content[0].text : '', + /registered alice \(plan: pro\)/ + ); + + formAction = 'decline'; + const declined = await client.callTool({ name: 'register_user' }); + check.match(declined.content?.[0]?.type === 'text' ? declined.content[0].text : '', /registration decline/); + + // ---- Multi-step form (two chained elicitations inside one tool call) ----- + const trip = await client.callTool({ name: 'plan_trip' }); + check.match(trip.content?.[0]?.type === 'text' ? trip.content[0].text : '', /trip planned: Tokyo on 2026-09-01 for 7 nights/); + + // ---- URL mode (push-style on 2025, inputRequired.elicitUrl on 2026) ------ + const linked = await client.callTool({ name: 'link_account', arguments: { provider: 'github' } }); + check.match(linked.content?.[0]?.type === 'text' ? linked.content[0].text : '', /linked github/); + + // ---- URL mode (throw-style — 2025-era only) ------------------------------ + if (eraLeg() === 'legacy') { + let caught: UrlElicitationRequiredError | undefined; + try { + await client.callTool({ name: 'confirm_payment', arguments: { cartId: 'cart-42' } }); + } catch (error) { + check.ok(error instanceof UrlElicitationRequiredError, 'expected UrlElicitationRequiredError'); + caught = error as UrlElicitationRequiredError; + } + check.ok(caught, 'confirm_payment should throw UrlElicitationRequiredError on the 2025 era'); + check.equal(caught?.elicitations.length, 1); + check.match(caught?.elicitations[0]?.url ?? '', /confirm-payment\?cart=cart-42/); + } + + await client.close(); +}); diff --git a/examples/elicitation/package.json b/examples/elicitation/package.json new file mode 100644 index 0000000000..437f49a350 --- /dev/null +++ b/examples/elicitation/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcp-examples/elicitation", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "dual", + "//": "2025-era push-style runs over the harness's sessionful http/legacy arm; 2026-07-28 inputRequired runs over the per-request modern arm. Full transport × era matrix." + } +} diff --git a/examples/elicitation/server.ts b/examples/elicitation/server.ts new file mode 100644 index 0000000000..7c60e9be06 --- /dev/null +++ b/examples/elicitation/server.ts @@ -0,0 +1,223 @@ +/** + * Elicitation — server requests user input. One factory, both protocol eras. + * + * The same tools serve both eras with different APIs: on a 2025-era + * connection (`--legacy`, the `initialize` handshake) the server uses the + * push-style server→client request flow — `ctx.mcpReq.elicitInput(...)` for + * form and URL mode, `UrlElicitationRequiredError` for the throw-style URL + * signal, and `createElicitationCompletionNotifier` for the out-of-band + * `notifications/elicitation/complete`. On a 2026-07-28 connection there is + * no server→client request channel: the same tools instead **return** + * `inputRequired(...)` (multi-round-trip) and the client retries with the + * collected responses. The protocol carries the request differently; the user + * experience is the same. + * + * One binary, either transport (selected by the shared scaffold from argv). + */ +import { randomUUID } from 'node:crypto'; + +import type { + CallToolResult, + ElicitRequestFormParams, + ElicitRequestURLParams, + InputRequiredResult, + McpRequestContext +} from '@modelcontextprotocol/server'; +import { acceptedContent, inputRequired, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +// The form schema (with `enumNames` display labels for the enum field). +const REGISTRATION_SCHEMA: ElicitRequestFormParams['requestedSchema'] = { + type: 'object', + properties: { + username: { type: 'string', title: 'Username', minLength: 3, maxLength: 20 }, + email: { type: 'string', title: 'Email', format: 'email' }, + plan: { + type: 'string', + title: 'Plan', + enum: ['free', 'pro', 'team'], + enumNames: ['Free tier', 'Pro', 'Team'] + } + }, + required: ['username', 'email'] +}; + +type Registration = { username: string; email: string; plan?: string }; + +function buildServer(reqCtx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'elicitation-example', version: '1.0.0' }); + + // ---- Form-mode elicitation ----------------------------------------------- + server.registerTool( + 'register_user', + { description: 'Register a new user account by collecting their information' }, + async (ctx): Promise => { + if (reqCtx.era === 'legacy') { + // 2025-era: push a server→client `elicitation/create` request and + // await the user's answer in-line. + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Please provide your registration information:', + requestedSchema: REGISTRATION_SCHEMA + }); + if (result.action !== 'accept' || !result.content) { + return { content: [{ type: 'text', text: `registration ${result.action}` }] }; + } + const { username, email, plan } = result.content as Registration; + return { content: [{ type: 'text', text: `registered ${username} <${email}> (plan: ${plan ?? 'free'})` }] }; + } + // 2026-07-28: return inputRequired — the client collects the form + // and retries this same handler with the response attached. + const response = ctx.mcpReq.inputResponses?.['form'] as { action?: string } | undefined; + if (!response) { + return inputRequired({ + inputRequests: { + form: inputRequired.elicit({ + message: 'Please provide your registration information:', + requestedSchema: REGISTRATION_SCHEMA + }) + } + }); + } + const form = acceptedContent(ctx.mcpReq.inputResponses, 'form'); + if (!form) { + return { content: [{ type: 'text', text: `registration ${response.action}` }] }; + } + return { content: [{ type: 'text', text: `registered ${form.username} <${form.email}> (plan: ${form.plan ?? 'free'})` }] }; + } + ); + + // ---- Multi-step / chained form elicitation (two sequential prompts) ------ + server.registerTool( + 'plan_trip', + { description: 'Plan a trip by collecting a destination and then dates for that destination' }, + async (ctx): Promise => { + const DEST: ElicitRequestFormParams['requestedSchema'] = { + type: 'object', + properties: { destination: { type: 'string', title: 'Destination' } }, + required: ['destination'] + }; + const datesFor = (dest: string): ElicitRequestFormParams['requestedSchema'] => ({ + type: 'object', + properties: { + departure: { type: 'string', title: `Departure date for ${dest}`, format: 'date' }, + nights: { type: 'integer', title: 'Nights', minimum: 1, maximum: 30 } + }, + required: ['departure', 'nights'] + }); + if (reqCtx.era === 'legacy') { + // 2025-era: two sequential `elicitation/create` pushes inside one tool call. + const step1 = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Where to?', requestedSchema: DEST }); + if (step1.action !== 'accept' || !step1.content) { + return { content: [{ type: 'text', text: `trip ${step1.action}` }] }; + } + const dest = step1.content.destination as string; + const step2 = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'When?', requestedSchema: datesFor(dest) }); + if (step2.action !== 'accept' || !step2.content) { + return { content: [{ type: 'text', text: `trip ${step2.action}` }] }; + } + return { + content: [ + { type: 'text', text: `trip planned: ${dest} on ${step2.content.departure} for ${step2.content.nights} nights` } + ] + }; + } + // 2026-07-28: two `inputRequired` rounds — the second carries the + // first answer back via `requestState` (an opaque server-minted + // string) so the chain survives the stateless retry. See ../mrtr/ + // for integrity-protecting `requestState` in production. + const dates = acceptedContent<{ departure: string; nights: number }>(ctx.mcpReq.inputResponses, 'dates'); + const destination = + ctx.mcpReq.requestState ?? acceptedContent<{ destination: string }>(ctx.mcpReq.inputResponses, 'dest')?.destination; + if (!destination) { + return inputRequired({ inputRequests: { dest: inputRequired.elicit({ message: 'Where to?', requestedSchema: DEST }) } }); + } + if (!dates) { + return inputRequired({ + requestState: destination, + inputRequests: { dates: inputRequired.elicit({ message: 'When?', requestedSchema: datesFor(destination) }) } + }); + } + return { content: [{ type: 'text', text: `trip planned: ${destination} on ${dates.departure} for ${dates.nights} nights` }] }; + } + ); + + // ---- URL-mode elicitation (push style + completion notification) --------- + server.registerTool( + 'link_account', + { + description: 'Link a third-party account by opening a sign-in URL', + inputSchema: z.object({ provider: z.string() }) + }, + async ({ provider }, ctx): Promise => { + if (reqCtx.era === 'legacy') { + // 2025-era push style: send `elicitation/create` (mode: 'url') + // and, in parallel, simulate the out-of-band callback that + // fires when the user finishes the URL flow by sending + // `notifications/elicitation/complete` for the same id. The + // client waits for that notification before answering accept. + const elicitationId = randomUUID(); + // Tie the completion notification to the in-flight request so on + // sessionful HTTP it travels over this POST's SSE response stream + // (rather than the standalone GET stream). + const notifyComplete = server.server.createElicitationCompletionNotifier(elicitationId, { + relatedRequestId: ctx.mcpReq.id + }); + setTimeout(() => void notifyComplete().catch(error => console.error('[server] complete notify failed:', error)), 50); + const params: ElicitRequestURLParams = { + mode: 'url', + message: `Sign in to ${provider} to link your account`, + url: `https://example.com/oauth/${encodeURIComponent(provider)}/authorize`, + elicitationId + }; + const result = await ctx.mcpReq.elicitInput(params); + return { content: [{ type: 'text', text: result.action === 'accept' ? `linked ${provider}` : `link ${result.action}` }] }; + } + // 2026-07-28: URL elicitation rides the multi-round-trip flow. No + // elicitationId / complete notification — correlation is the + // server's own state across retries. + const auth = ctx.mcpReq.inputResponses?.['auth'] as { action?: string } | undefined; + if (auth?.action !== 'accept') { + return inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ + message: `Sign in to ${provider} to link your account`, + url: `https://example.com/oauth/${encodeURIComponent(provider)}/authorize` + }) + } + }); + } + return { content: [{ type: 'text', text: `linked ${provider}` }] }; + } + ); + + // ---- URL-mode elicitation (throw style, 2025-era only) ------------------- + // The error-style signal: the tool THROWS `UrlElicitationRequiredError` + // (wire `-32042`); the client catches it as a typed error and reads + // `.elicitations`. There is no 2026-07-28 equivalent — a throw on that era + // fails loudly with a steer to `inputRequired.elicitUrl(...)`. + server.registerTool( + 'confirm_payment', + { + description: 'Confirm a payment via a browser flow (2025-era throw-style URL elicitation)', + inputSchema: z.object({ cartId: z.string() }) + }, + async ({ cartId }): Promise => { + throw new UrlElicitationRequiredError([ + { + mode: 'url', + message: 'Open the link to confirm payment', + url: `https://example.com/confirm-payment?cart=${encodeURIComponent(cartId)}`, + elicitationId: randomUUID() + } + ]); + } + ); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/eslint.config.mjs b/examples/eslint.config.mjs new file mode 100644 index 0000000000..807cb09106 --- /dev/null +++ b/examples/eslint.config.mjs @@ -0,0 +1,43 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + // The nested workspace packages (shared, *-quickstart) are linted by their own configs. + ignores: ['shared/**', 'server-quickstart/**', 'client-quickstart/**'] + }, + { + files: ['**/*.{ts,tsx,js,jsx,mts,cts}'], + rules: { + // Examples write to stdout/stderr deliberately. + 'no-console': 'off', + // Story client.ts files are self-verifying tests that exit non-zero on failure. + 'unicorn/no-process-exit': 'off', + // Examples MUST use only what a consumer would `npm install` and import: + // public package entry points and the local harness. Anything reaching into + // package internals or workspace source is banned. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { group: ['@modelcontextprotocol/*/src/*'], message: 'Examples must import only public package entry points.' }, + { + group: ['**/packages/*', '../../packages/*', '../../../packages/*'], + message: 'Examples must not reach into workspace source.' + }, + { + group: ['@modelcontextprotocol/core', '@modelcontextprotocol/core/*'], + message: 'Examples must import from @modelcontextprotocol/{server,client}, not core.' + }, + { + group: ['@modelcontextprotocol/test-helpers', '@modelcontextprotocol/test-helpers/*'], + message: 'Examples must not depend on test helpers.' + } + ] + } + ] + } + } +]; diff --git a/examples/guides/README.md b/examples/guides/README.md new file mode 100644 index 0000000000..d0ed7dbe93 --- /dev/null +++ b/examples/guides/README.md @@ -0,0 +1,3 @@ +# guides + +Snippet collections synced into `docs/server.md` and `docs/client.md` via `pnpm sync:snippets`. Typecheck-only — these are not runnable programs. diff --git a/examples/client/src/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts similarity index 100% rename from examples/client/src/clientGuide.examples.ts rename to examples/guides/clientGuide.examples.ts diff --git a/examples/server/src/serverGuide.examples.ts b/examples/guides/serverGuide.examples.ts similarity index 100% rename from examples/server/src/serverGuide.examples.ts rename to examples/guides/serverGuide.examples.ts diff --git a/examples/harness.ts b/examples/harness.ts new file mode 100644 index 0000000000..c32db17911 --- /dev/null +++ b/examples/harness.ts @@ -0,0 +1,218 @@ +/** + * Tiny dual-transport scaffold shared by every `examples//` pair. + * + * The same factory backs both transports of one example: a story's `server.ts` + * calls {@linkcode runServerFromArgs} so one binary serves stdio (default) or + * HTTP under `--http --port `; its `client.ts` calls + * {@linkcode connectFromArgs} so one binary spawns the sibling server over + * stdio (default) or connects to a running endpoint under `--http `, and + * negotiates the modern (2026-07-28) era by default or the 2025 `initialize` + * handshake under `--legacy`. The client's body is wrapped in + * {@linkcode runClient} so any thrown assertion exits non-zero with a `FAIL:` + * line, making each example a self-verifying e2e test that + * `scripts/run-examples.ts` can iterate over the transport × era matrix. + * + * Re-exported `check` is `node:assert/strict` for readable inline assertions. + */ + +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createServer } from 'node:http'; +import path from 'node:path'; + +import type { ClientOptions } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { McpServerFactory } from '@modelcontextprotocol/server'; +import { createMcpHandler, isInitializeRequest, isLegacyRequest } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +export { strict as check } from 'node:assert'; + +/** + * Serve the given factory over EITHER transport, selected from `process.argv`. + * + * - default: `serveStdio(factory)` — the deployable shape; the client spawns + * this binary and speaks JSON-RPC over the pipe. + * - `--http [--port N]`: the documented {@linkcode isLegacyRequest} composition + * on `node:http` at `/` — modern (2026-07-28) traffic via a strict + * `createMcpHandler(factory, { legacy: 'reject' })`, 2025-era traffic via a + * sessionful `NodeStreamableHTTPServerTransport` (one transport+instance per + * session, the way you would actually deploy a 2025 server). The same + * factory backs both arms. + * + * Logs go to **stderr** so stdio's stdout JSON-RPC stream stays clean. + */ +export function runServerFromArgs(factory: McpServerFactory, defaultPort = 3000): void { + const argv = process.argv.slice(2); + if (argv.includes('--http')) { + const portIdx = argv.indexOf('--port'); + const port = portIdx === -1 ? Number(process.env.PORT ?? defaultPort) : Number(argv[portIdx + 1]); + + // --- modern (2026-07-28): per-request, strict so the sessionful arm owns ALL legacy traffic --- + const modern = createMcpHandler(factory, { + legacy: 'reject', + onerror: e => console.error('[server] handler error:', e.message) + }); + + // --- legacy (2025): sessionful streamable HTTP — the deployable shape --- + const sessions = new Map(); + const handleLegacy = async (req: IncomingMessage, res: ServerResponse, body: unknown): Promise => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, body); + } else if (!sid && isInitializeRequest(body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + const instance = await factory({ era: 'legacy' }); + await instance.connect(transport); + await transport.handleRequest(req, res, body); + } else if (sid) { + res.writeHead(404, { 'content-type': 'application/json' }).end( + JSON.stringify({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }) + ); + } else { + res.writeHead(400, { 'content-type': 'application/json' }).end( + JSON.stringify({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }) + ); + } + }; + + const server = createServer((req, res) => { + void (async () => { + // Read the body once for the predicate and pass it forward. + let body: unknown; + if (req.method === 'POST') { + // Collect Buffers and decode once so multi-byte UTF-8 sequences split across chunk + // boundaries (>~16 KiB bodies) aren't mojibaked into U+FFFD by per-chunk String(). + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString('utf8'); + try { + body = raw ? JSON.parse(raw) : undefined; + } catch { + body = undefined; + } + } + const probe = new globalThis.Request(`http://localhost${req.url ?? '/'}`, { + method: req.method, + headers: req.headers as Record + }); + await ((await isLegacyRequest(probe, body)) ? handleLegacy(req, res, body) : modern.node(req, res, body)); + })().catch(error => { + console.error('[server] request error:', error instanceof Error ? error.message : error); + if (!res.headersSent) res.writeHead(500).end(); + }); + }); + server.listen(port, () => console.error(`[server] listening on http://127.0.0.1:${port}/ (HTTP)`)); + const exit = async () => { + await modern.close(); + for (const t of sessions.values()) await t.close().catch(() => {}); + server.close(); + process.exit(0); + }; + process.on('SIGINT', exit); + process.on('SIGTERM', exit); + } else { + const handle = serveStdio(factory); + console.error('[server] serving over stdio'); + const exit = async () => { + await handle.close(); + process.exit(0); + }; + process.on('SIGINT', exit); + process.on('SIGTERM', exit); + } +} + +/** + * Construct a {@link Client} and connect it over EITHER transport, selected + * from `process.argv`. Under `--http ` it connects to the given endpoint + * via Streamable HTTP; otherwise it spawns the sibling `server.ts` (resolved + * relative to the calling client's `import.meta.dirname`) via stdio. + * + * The protocol era is selected from `process.argv` too: under `--legacy` the + * client uses `versionNegotiation: { mode: 'legacy' }` (the plain 2025 + * `initialize` handshake); otherwise `{ mode: 'auto' }` so the + * `server/discover` probe negotiates the 2026-07-28 revision against either + * transport without per-story envelope plumbing. Pass + * `options.versionNegotiation` explicitly to opt out (for stories that drive + * both eras within one body). + */ +export async function connectFromArgs(siblingDir: string, options: ClientOptions = {}): Promise { + const argv = process.argv.slice(2); + const client = new Client( + { name: `${path.basename(siblingDir)}-example-client`, version: '1.0.0' }, + { versionNegotiation: negotiationFromArgs(), ...options } + ); + const httpIdx = argv.indexOf('--http'); + if (httpIdx === -1) { + const serverSource = path.resolve(siblingDir, 'server.ts'); + await client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', serverSource] })); + } else { + const url = argv[httpIdx + 1] ?? 'http://127.0.0.1:3000/'; + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(url))); + } + return client; +} + +/** Transport leg the client is running on this invocation. */ +export function transportLeg(): 'stdio' | 'http' { + return process.argv.includes('--http') ? 'http' : 'stdio'; +} + +/** Protocol-era leg the client is running on this invocation. */ +export function eraLeg(): 'modern' | 'legacy' { + return process.argv.includes('--legacy') ? 'legacy' : 'modern'; +} + +/** + * The `versionNegotiation` ClientOption derived from `process.argv` — the same + * value {@linkcode connectFromArgs} applies. Use it from stories that + * construct their own {@link Client} so the harness's `--legacy` flag still + * selects the era. + */ +export function negotiationFromArgs(): NonNullable { + return { mode: process.argv.includes('--legacy') ? 'legacy' : 'auto' }; +} + +/** + * The `--http ` argument from `process.argv`, or `defaultUrl` when the + * flag (or its value) is absent. HTTP-only stories that construct their own + * transport call this instead of {@linkcode connectFromArgs}. (A bare + * `argv[argv.indexOf('--http') + 1]` reads `argv[0]` — the script path — when + * the flag is missing, so the `?? default` never applies.) + */ +export function httpUrlFromArgs(defaultUrl: string): string { + const argv = process.argv.slice(2); + const i = argv.indexOf('--http'); + if (i === -1) return defaultUrl; + return argv[i + 1] ?? defaultUrl; +} + +/** + * Run a self-verifying client scenario. Any thrown error (including + * `node:assert/strict` failures) prints a `FAIL:` line to stderr and exits + * non-zero so the harness records the failure; on success it prints an `OK:` + * line and exits 0. + */ +export function runClient(name: string, scenario: () => Promise): void { + void (async () => { + const leg = `${transportLeg()}/${eraLeg()}`; + try { + await scenario(); + console.log(`OK: ${name} (${leg})`); + process.exit(0); + } catch (error) { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + console.error(`FAIL: ${name} (${leg}): ${message}`); + process.exit(1); + } + })(); +} diff --git a/examples/hono/README.md b/examples/hono/README.md new file mode 100644 index 0000000000..6a17754900 --- /dev/null +++ b/examples/hono/README.md @@ -0,0 +1,6 @@ +# hono + +`createMcpHandler(...).fetch` mounted on a Hono app — the web-standard face that runs on Cloudflare Workers, Deno, Bun and Node.js (via `@hono/node-server`). The `@modelcontextprotocol/hono` adapter (`createMcpHonoApp()`) arms localhost DNS-rebinding / origin protection by +default. + +**HTTP-only** by definition. diff --git a/examples/hono/client.ts b/examples/hono/client.ts new file mode 100644 index 0000000000..6991003bc7 --- /dev/null +++ b/examples/hono/client.ts @@ -0,0 +1,20 @@ +/** + * Connects to the Hono-hosted server, lists tools and calls `greet`. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; + +const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); + +runClient('hono', async () => { + // `createMcpHandler.fetch` serves both eras (default `'stateless'` posture); + // `negotiationFromArgs()` honours `--legacy` so the harness runs both. + const client = new Client({ name: 'hono-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const tools = await client.listTools(); + check.ok(tools.tools.some(t => t.name === 'greet')); + const result = await client.callTool({ name: 'greet', arguments: { name: 'hono' } }); + check.match(result.content?.[0]?.type === 'text' ? result.content[0].text : '', /Hello, hono!/); + await client.close(); +}); diff --git a/examples/hono/package.json b/examples/hono/package.json new file mode 100644 index 0000000000..e8bcbbfcc3 --- /dev/null +++ b/examples/hono/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mcp-examples/hono", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "//": "createMcpHandler.fetch hosting is era-agnostic (default 'stateless' posture serves both); the client honours --legacy via negotiationFromArgs." + } +} diff --git a/examples/hono/server.ts b/examples/hono/server.ts new file mode 100644 index 0000000000..f5cf1ab3bd --- /dev/null +++ b/examples/hono/server.ts @@ -0,0 +1,34 @@ +/** + * Hosting on Hono / web-standard runtimes (Cloudflare Workers, Deno, Bun, + * Node.js via `@hono/node-server`). + * + * `createMcpHandler(...).fetch` is the web-standard face: pass the raw + * `Request` and return the `Response`. The `@modelcontextprotocol/hono` + * package adds the same DNS-rebinding / origin protection middleware the + * Express adapter ships. + */ +import { serve } from '@hono/node-server'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'hono-example', version: '1.0.0' }); + server.registerTool( + 'greet', + { title: 'Greeting Tool', description: 'A simple greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (from Hono + createMcpHandler.fetch)` }] }) + ); + return server; +}); + +// `createMcpHonoApp()` arms localhost host/origin validation by default. +const app = createMcpHonoApp(); +app.get('/health', c => c.json({ status: 'ok' })); +app.all('/mcp', c => handler.fetch(c.req.raw)); + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? Number(process.env.MCP_PORT ?? 3000) : Number(argv[portIdx + 1]); +console.error(`hono example server listening on http://127.0.0.1:${port}/mcp`); +serve({ fetch: app.fetch, port }); diff --git a/examples/json-response/README.md b/examples/json-response/README.md new file mode 100644 index 0000000000..b15f133209 --- /dev/null +++ b/examples/json-response/README.md @@ -0,0 +1,5 @@ +# json-response + +`createMcpHandler({ responseMode: 'json' })` — a single `application/json` body per request instead of an SSE stream. Useful for serverless / edge runtimes that can't hold a stream open. Mid-call notifications are dropped. + +**HTTP-only** by definition. diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts new file mode 100644 index 0000000000..147d91f097 --- /dev/null +++ b/examples/json-response/client.ts @@ -0,0 +1,45 @@ +/** + * Asserts the `responseMode: 'json'` server answers a `tools/call` with a + * `Content-Type: application/json` body (not `text/event-stream`) AND that the + * regular `Client` works against it unchanged. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, runClient } from '../harness.js'; + +const URL = httpUrlFromArgs('http://127.0.0.1:3000/'); + +runClient('json-response', async () => { + // Low-level: a 2026-07-28 (envelope) request should come back as plain + // JSON. (`responseMode` applies to the per-request modern path; 2025-era + // traffic goes through the stateless legacy fallback unaffected.) + const probe = await fetch(URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'probe', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} + } + } + }) + }); + check.match(probe.headers.get('content-type') ?? '', /application\/json/); + check.equal(probe.status, 200); + + // High-level: the regular Client works unchanged. + const client = new Client({ name: 'json-response-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const result = await client.callTool({ name: 'greet', arguments: { name: 'json' } }); + check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'Hello, json!'); + await client.close(); +}); diff --git a/examples/json-response/package.json b/examples/json-response/package.json new file mode 100644 index 0000000000..242ccce148 --- /dev/null +++ b/examples/json-response/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mcp-examples/json-response", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "responseMode shapes the modern (2026-07-28) per-request path only; 2025-era traffic goes through the stateless legacy fallback unaffected, so a legacy leg would not exercise the option." + } +} diff --git a/examples/json-response/server.ts b/examples/json-response/server.ts new file mode 100644 index 0000000000..a5c82f883d --- /dev/null +++ b/examples/json-response/server.ts @@ -0,0 +1,30 @@ +/** + * `createMcpHandler` with `responseMode: 'json'` — single JSON response + * instead of an SSE stream. Useful for serverless deployments that can't + * hold a stream open. Mid-call notifications are dropped (the handler logs a + * warning at construction time). + */ +import { createServer } from 'node:http'; + +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler( + () => { + const server = new McpServer({ name: 'json-response-example', version: '1.0.0' }); + server.registerTool( + 'greet', + { description: 'A simple greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) + ); + return server; + }, + { responseMode: 'json' } +); + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +createServer((req, res) => void handler.node(req, res)).listen(port, () => { + console.error(`json-response example server listening on http://127.0.0.1:${port}/`); +}); diff --git a/examples/legacy-routing/README.md b/examples/legacy-routing/README.md new file mode 100644 index 0000000000..21a2a38e9e --- /dev/null +++ b/examples/legacy-routing/README.md @@ -0,0 +1,26 @@ +# legacy-routing + +`isLegacyRequest` routing: keep an **existing** sessionful 1.x Streamable HTTP deployment serving 2025-era clients, add a strict `createMcpHandler({ legacy: 'reject' })` for 2026-07-28 traffic, on the **same port**. The predicate decides per request which arm handles it. + +`server.ts` also shows the browser-client CORS `exposedHeaders` recipe and explicit `GET` (standalone SSE stream) / `DELETE` (session termination per the MCP spec) routes for the sessionful arm. + +**HTTP-only** by definition; see also `dual-era/` for the simple case where you don't have a sessionful deployment to keep. + +## Direct transport construction (without `createMcpHandler`) + +If you need full control over the per-request transport on a web-standards runtime (Hono, Cloudflare Workers, …) instead of `createMcpHandler`, construct `WebStandardStreamableHTTPServerTransport` directly: + +```ts +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; + +const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID() +}); +const server = new McpServer({ name: 'direct-transport', version: '1.0.0' }); +await server.connect(transport); + +// Any Request/Response runtime (fetch handler, Hono `c.req.raw`, …): +export default { fetch: (request: Request) => transport.handleRequest(request) }; +``` + +`NodeStreamableHTTPServerTransport` (used in this story's legacy arm) is the Node.js `IncomingMessage`/`ServerResponse` equivalent. diff --git a/examples/legacy-routing/client.ts b/examples/legacy-routing/client.ts new file mode 100644 index 0000000000..33c71794ce --- /dev/null +++ b/examples/legacy-routing/client.ts @@ -0,0 +1,27 @@ +/** + * Connects to the routing fork as both a plain 2025 client (lands on the + * existing sessionful transport, `era=legacy`) and a 2026-capable client + * (lands on the strict modern entry, `era=modern`). + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, runClient } from '../harness.js'; + +const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); + +runClient('legacy-routing', async () => { + // 2025 client → routed to the existing sessionful deployment. + const legacy = new Client({ name: 'legacy-routing-client', version: '1.0.0' }); + await legacy.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const lr = await legacy.callTool({ name: 'greet', arguments: { name: 'A' } }); + check.match(lr.content?.[0]?.type === 'text' ? lr.content[0].text : '', /era=legacy/); + await legacy.close(); + + // 2026 client → routed to the strict modern entry. + const modern = new Client({ name: 'legacy-routing-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await modern.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + check.equal(modern.getNegotiatedProtocolVersion(), '2026-07-28'); + const mr = await modern.callTool({ name: 'greet', arguments: { name: 'B' } }); + check.match(mr.content?.[0]?.type === 'text' ? mr.content[0].text : '', /era=modern/); + await modern.close(); +}); diff --git a/examples/legacy-routing/package.json b/examples/legacy-routing/package.json new file mode 100644 index 0000000000..2edf1cfe91 --- /dev/null +++ b/examples/legacy-routing/package.json @@ -0,0 +1,29 @@ +{ + "name": "@mcp-examples/legacy-routing", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@types/cors": "catalog:devTools", + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "The story body drives BOTH eras itself (one legacy + one modern client against the same port); pinned so the harness runs it once." + } +} diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts new file mode 100644 index 0000000000..2e9d5a583f --- /dev/null +++ b/examples/legacy-routing/server.ts @@ -0,0 +1,93 @@ +/** + * `isLegacyRequest` routing in front of an existing sessionful 1.x deployment, + * with a strict modern entry on the SAME port. + * + * This is the v2 answer to "I already have a sessionful Streamable HTTP + * deployment and want to add 2026-07-28 serving without disturbing it": + * route in user land — `await isLegacyRequest(req)` decides per request, + * legacy traffic goes to your existing transport, modern traffic to a strict + * `createMcpHandler(factory, { legacy: 'reject' })`. + * + * HTTP-only by definition. + */ +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, isInitializeRequest, isLegacyRequest, McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +// One factory for both legs. +const buildServer = (era: 'legacy' | 'modern') => { + const server = new McpServer({ name: 'legacy-routing-example', version: '1.0.0' }); + server.registerTool('greet', { description: 'Greets the caller', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}! (era=${era})` }] + })); + return server; +}; + +// --- the existing sessionful 2025 deployment, unchanged --- +const sessions = new Map(); +const handleLegacy = async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + await buildServer('legacy').connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + // Unknown session ID → 404 so the client knows to start a new session. + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); + } +}; + +// --- the strict modern entry alongside it --- +const modern = createMcpHandler((ctx: McpRequestContext) => buildServer(ctx.era), { legacy: 'reject' }); + +const app = createMcpExpressApp(); +// Browser-client CORS recipe: expose the response headers a browser-based MCP +// client must be able to read (`Mcp-Session-Id` for session correlation, +// `WWW-Authenticate` for the auth challenge, `Last-Event-Id` for resumability, +// `Mcp-Protocol-Version` for negotiation). DEMO ONLY — restrict `origin` in +// production. +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', 'Last-Event-Id', 'Mcp-Protocol-Version'] + }) +); + +app.post('/mcp', async (req: Request, res: Response) => { + // The predicate inspects the same headers + body the entry does. Express + // has parsed the JSON body; pass it as `parsedBody` so the predicate need + // not re-read the stream. + const probe = new globalThis.Request(`http://localhost${req.url}`, { + method: req.method, + headers: req.headers as Record + }); + await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modern.node(req, res, req.body)); +}); +// GET (standalone SSE stream / reconnect with Last-Event-ID) and DELETE +// (explicit session termination per the MCP spec) are sessionful-2025-only — +// route them straight to the legacy arm; the transport handles each verb. +app.get('/mcp', (req, res) => void handleLegacy(req, res)); +app.delete('/mcp', (req, res) => void handleLegacy(req, res)); + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +app.listen(port, () => { + console.error(`legacy-routing example server listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/mrtr/README.md b/examples/mrtr/README.md new file mode 100644 index 0000000000..d2ef926de6 --- /dev/null +++ b/examples/mrtr/README.md @@ -0,0 +1,10 @@ +# mrtr (multi-round-trip requests) + +A write-once `deploy` tool that requests client input by **returning** `inputRequired(...)` instead of pushing a server→client request (protocol revision 2026-07-28). State between rounds is carried in `requestState`, which the example HMAC-protects and verifies via the +`ServerOptions.requestState.verify` hook (a wire-level `-32602` on tamper). + +The client drives both the default auto-fulfilment mode (your existing `elicitation/create` handler is dispatched for you and `callTool()` returns a plain `CallToolResult`) and manual mode (`autoFulfill: false` + `allowInputRequired: true`). + +```bash +pnpm tsx examples/mrtr/client.ts +``` diff --git a/examples/client/src/multiRoundTripClient.ts b/examples/mrtr/client.ts similarity index 50% rename from examples/client/src/multiRoundTripClient.ts rename to examples/mrtr/client.ts index 13921bdd95..aeb13e3ba6 100644 --- a/examples/client/src/multiRoundTripClient.ts +++ b/examples/mrtr/client.ts @@ -1,6 +1,5 @@ /** - * Drives the multi-round-trip server example - * (`examples/server/src/multiRoundTrip.ts`) two ways on a 2026-07-28 + * Drives the multi-round-trip server (`./server.ts`) two ways on a 2026-07-28 * connection: * * 1. **auto-fulfilment** (the default) — the same `elicitation/create` @@ -13,61 +12,43 @@ * the example collects responses, echoes `requestState`, and retries * itself. * - * Start the server first, then: - * - * tsx examples/client/src/multiRoundTripClient.ts + * Asserts both flows reach `deployed to …` and exits 0. */ import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/client'; -import { Client, isInputRequiredResult, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { isInputRequiredResult } from '@modelcontextprotocol/client'; -const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/'; -const CLIENT_INFO = { name: 'mrtr-example-client', version: '1.0.0' }; +import { check, connectFromArgs, runClient } from '../harness.js'; -async function autoFulfilLeg(): Promise { - console.log('--- auto-fulfilment (the default) ---'); - const client = new Client(CLIENT_INFO, { - versionNegotiation: { mode: 'auto' }, +runClient('mrtr', async () => { + // --- auto-fulfilment (the default) --- + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const auto = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {}, url: {} } } }); // The SAME handler a 2025-flow client registers: the auto-fulfilment // engine dispatches embedded form and URL elicitations through it. - client.setRequestHandler('elicitation/create', async request => { + auto.setRequestHandler('elicitation/create', async request => { const params = request.params as { mode?: string; message: string; url?: string }; - if (params.mode === 'url') { - console.log(`[client] (auto) url elicitation: ${params.message} → ${params.url}`); - return { action: 'accept' }; - } - console.log(`[client] (auto) form elicitation: ${params.message}`); + if (params.mode === 'url') return { action: 'accept' }; return { action: 'accept', content: { confirm: true } }; }); - - await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); - // callTool returns a plain CallToolResult — the interactive rounds happen // inside the call. - const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); - console.log('deploy result:', JSON.stringify(result.content)); - await client.close(); -} + const autoResult = await auto.callTool({ name: 'deploy', arguments: { env: 'prod' } }); + const autoText = autoResult.content?.[0]?.type === 'text' ? autoResult.content[0].text : ''; + check.equal(autoText, 'deployed to prod'); + await auto.close(); -async function manualLeg(): Promise { - console.log('--- manual mode (autoFulfill: false + allowInputRequired) ---'); - const client = new Client(CLIENT_INFO, { - versionNegotiation: { mode: 'auto' }, + // --- manual mode (autoFulfill: false + allowInputRequired) --- + const manual = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {}, url: {} } }, inputRequired: { autoFulfill: false } }); - await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - let inputResponses: Record | undefined; let requestState: string | undefined; + let final: CallToolResult | undefined; for (let round = 0; round < 10; round++) { - // allowInputRequired: true → the call resolves with either the - // complete CallToolResult or the input-required value (use - // `withInputRequired(schema)` on the explicit-schema path to type - // both outcomes; here the method-keyed path is used for brevity). - const value = (await client.request( + const value = (await manual.request( { method: 'tools/call', params: { @@ -80,19 +61,18 @@ async function manualLeg(): Promise { { allowInputRequired: true } )) as CallToolResult | InputRequiredResult; if (!isInputRequiredResult(value)) { - console.log('deploy result:', JSON.stringify(value.content)); + final = value; break; } // Collect responses and echo requestState byte-exact. - console.log(`[client] (manual) round ${round + 1}: server asked for ${Object.keys(value.inputRequests ?? {}).join(', ')}`); inputResponses = {}; for (const [key, entry] of Object.entries(value.inputRequests ?? {})) { inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {}; } requestState = value.requestState; } - await client.close(); -} - -await autoFulfilLeg(); -await manualLeg(); + check.ok(final, 'manual flow should reach a CallToolResult within 10 rounds'); + const manualText = final?.content?.[0]?.type === 'text' ? final.content[0].text : ''; + check.equal(manualText, 'deployed to staging'); + await manual.close(); +}); diff --git a/examples/mrtr/package.json b/examples/mrtr/package.json new file mode 100644 index 0000000000..59e7ebc322 --- /dev/null +++ b/examples/mrtr/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcp-examples/mrtr", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "Multi-round-trip inputRequired is a 2026-07-28 protocol feature." + } +} diff --git a/examples/server/src/multiRoundTrip.ts b/examples/mrtr/server.ts similarity index 85% rename from examples/server/src/multiRoundTrip.ts rename to examples/mrtr/server.ts index 51abba4eb2..db4f81105e 100644 --- a/examples/server/src/multiRoundTrip.ts +++ b/examples/mrtr/server.ts @@ -1,6 +1,6 @@ /** - * A write-once tool served via `createMcpHandler` that requests client input - * with multi round-trip results (protocol revision 2026-07-28). + * A write-once tool that requests client input with multi-round-trip results + * (protocol revision 2026-07-28). * * The `deploy` tool returns `inputRequired(...)` instead of pushing a * server→client request: a form-mode elicitation for confirmation, then a @@ -15,21 +15,16 @@ * key and rejects tampered state via the {@linkcode ServerOptions.requestState} * `verify` hook, which answers a wire-level `-32602` Invalid Params error. * - * Run with: - * - * tsx examples/server/src/multiRoundTrip.ts - * - * and point the paired client example at it: - * - * tsx examples/client/src/multiRoundTripClient.ts + * One binary, either transport (selected by the shared scaffold from argv). */ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; -import { createServer } from 'node:http'; import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server'; -import { acceptedContent, createMcpHandler, inputRequired, McpServer } from '@modelcontextprotocol/server'; +import { acceptedContent, inputRequired, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; +import { runServerFromArgs } from '../harness.js'; + const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] }; // Per-process integrity key for requestState. The 2026-07-28 path serves every @@ -124,12 +119,5 @@ function buildServer(): McpServer { return server; } -// Host with the per-request HTTP entry on its default posture (2026-07-28 -// served per request; 2025-era traffic served stateless from the same -// factory). -const handler = createMcpHandler(() => buildServer()); -const port = Number(process.env.PORT ?? '3000'); - -createServer((req, res) => void handler.node(req, res)).listen(port, () => { - console.error(`multi-round-trip example server listening on http://localhost:${port}/`); -}); +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/oauth-client-credentials/README.md b/examples/oauth-client-credentials/README.md new file mode 100644 index 0000000000..c99a850477 --- /dev/null +++ b/examples/oauth-client-credentials/README.md @@ -0,0 +1,41 @@ +# oauth-client-credentials + +OAuth 2.0 **`client_credentials`** grant — machine-to-machine MCP auth, fully self-verifying with no browser. + +`client_credentials` is the grant a backend service uses to authenticate **as itself** (not on behalf of a user): it presents a pre-registered `client_id`/`client_secret` directly to the Authorization Server's token endpoint and receives a Bearer access token. There is no +redirect, no authorization code, no user consent screen. + +The interactive **authorization-code** flow (the one that opens a browser and asks a human to sign in) lives under [`../oauth/`](../oauth/README.md); the harness runs it headlessly via the demo AS's `OAUTH_DEMO_AUTO_CONSENT=1` auto-approve mode. + +## What runs + +- `server.ts` starts two listeners in one process: + - the MCP **resource server** on `--port` — `createMcpHandler` behind `requireBearerAuth` from `@modelcontextprotocol/express`, advertising the AS via `mcpAuthMetadataRouter` (RFC 9728 + RFC 8414). + - a minimal **`client_credentials`-only Authorization Server** on `--port + 1` (`createClientCredentialsAuthServer` from `@mcp-examples/shared`). The repo's full better-auth/OIDC demo AS only implements `authorization_code`, so this story ships its own purpose-built AS. +- `client.ts` first asserts a bare request is `401` with a `WWW-Authenticate` challenge, then connects with a `ClientCredentialsProvider` on the transport. The SDK auth driver discovers the AS from the challenge, posts `grant_type=client_credentials` (HTTP Basic auth) to + `/token`, attaches the returned Bearer token, and the `whoami` tool's `ctx.authInfo` carries the granted `clientId` and `scopes` end to end. + +## Run it + +```bash +pnpm --filter @mcp-examples/oauth-client-credentials server -- --http --port 3000 +pnpm --filter @mcp-examples/oauth-client-credentials client -- --http http://127.0.0.1:3000/mcp +``` + +HTTP-only; runs on both protocol eras (the client honours `--legacy` via `negotiationFromArgs()`). + +## `private_key_jwt` client authentication + +To authenticate the `client_credentials` grant with a signed JWT assertion (RFC 7523 §2.2) instead of a shared secret, swap `ClientCredentialsProvider` for `PrivateKeyJwtProvider`: + +```ts +import { PrivateKeyJwtProvider } from '@modelcontextprotocol/client'; + +const authProvider = new PrivateKeyJwtProvider({ + clientId: 'my-service', + privateKey: pemEncodedKey, + algorithm: 'RS256' +}); +``` + +The full snippet lives in the client guide (`docs/client.md` → `auth_privateKeyJwt`). There is no runnable leg for it in this story — the in-repo `client_credentials` AS only implements `client_secret_basic`/`client_secret_post`. diff --git a/examples/oauth-client-credentials/client.ts b/examples/oauth-client-credentials/client.ts new file mode 100644 index 0000000000..5136efcf71 --- /dev/null +++ b/examples/oauth-client-credentials/client.ts @@ -0,0 +1,58 @@ +/** + * Self-verifying `client_credentials` client. + * + * 1. A bare request is `401` with a `WWW-Authenticate` challenge that names the + * Protected Resource Metadata URL. + * 2. A `Client` with a {@linkcode ClientCredentialsProvider} on its transport + * follows that challenge → AS metadata → `POST /token` with + * `grant_type=client_credentials` (HTTP Basic `client_id:client_secret`) → + * Bearer token → reaches the `whoami` tool, whose `ctx.authInfo` carries the + * granted scopes. + * + * No browser, no readline. The SDK's auth driver does the discovery; the only + * thing the caller supplies is the pre-registered client's id+secret. + */ +import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; + +const URL_ARG = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); + +runClient('oauth-client-credentials', async () => { + // Unauthenticated → 401 + WWW-Authenticate naming the PRM URL. + const unauth = await fetch(URL_ARG, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) + }); + check.equal(unauth.status, 401, 'bare request must be 401'); + check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/); + check.match(unauth.headers.get('www-authenticate') ?? '', /oauth-protected-resource/); + + // Authenticated via client_credentials → 200, ctx.authInfo carries the granted scopes. + const provider = new ClientCredentialsProvider({ + clientId: 'demo-m2m-client', + clientSecret: 'demo-m2m-secret', + scope: 'mcp:tools mcp:read' + }); + const client = new Client({ name: 'client-credentials-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider })); + + const tokens = provider.tokens(); + check.ok(tokens?.access_token, 'ClientCredentialsProvider obtained an access_token'); + check.equal(tokens?.token_type, 'Bearer'); + + const result = await client.callTool({ name: 'whoami', arguments: {} }); + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''; + const seen = JSON.parse(text) as { clientId: string; scopes: string[] }; + check.equal(seen.clientId, 'demo-m2m-client', 'ctx.authInfo.clientId round-trips'); + check.ok(seen.scopes.includes('mcp:tools'), 'ctx.authInfo.scopes carries the granted scope'); + + // Expiry: both the demo verifier and `requireBearerAuth` reject when + // `AuthInfo.expiresAt` is in the past, so an expired token would 401 here + // exactly like the bare-request leg above. Minting an expired token would + // mean reaching past the AS's public surface, so the path is documented + // rather than exercised. + + await client.close(); +}); diff --git a/examples/oauth-client-credentials/package.json b/examples/oauth-client-credentials/package.json new file mode 100644 index 0000000000..c0ee598b83 --- /dev/null +++ b/examples/oauth-client-credentials/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mcp-examples/oauth-client-credentials", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "//": "OAuth client_credentials is HTTP-layer and era-agnostic; the client honours --legacy via negotiationFromArgs." + } +} diff --git a/examples/oauth-client-credentials/server.ts b/examples/oauth-client-credentials/server.ts new file mode 100644 index 0000000000..f125f4cd2b --- /dev/null +++ b/examples/oauth-client-credentials/server.ts @@ -0,0 +1,76 @@ +/** + * OAuth 2.0 **`client_credentials`** grant — the machine-to-machine story. + * + * One process hosts both halves on adjacent ports: + * + * - `:PORT` — the MCP **Resource Server**: `createMcpHandler` behind + * `requireBearerAuth`, advertising the AS via `mcpAuthMetadataRouter` + * (RFC 9728 Protected Resource Metadata + RFC 8414 AS metadata). + * - `:PORT+1` — a minimal in-repo **Authorization Server** that supports the + * `client_credentials` grant only (`@mcp-examples/shared`'s + * `createClientCredentialsAuthServer`). The full better-auth/OIDC demo AS + * only implements `authorization_code`, hence this purpose-built one. + * + * The client (`./client.ts`) discovers the AS from a 401 challenge, exchanges + * its `client_id`/`client_secret` for a Bearer token at `/token`, and reaches + * the `whoami` tool — which echoes `ctx.authInfo` so the client can assert the + * granted scopes round-tripped end to end. HTTP-only by definition. + */ +import { createClientCredentialsAuthServer } from '@mcp-examples/shared'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const PORT = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +const AUTH_PORT = PORT + 1; +// 127.0.0.1 (not `localhost`) so the PRM `resource` value matches the URL the +// harness passes the client byte-for-byte — the SDK auth driver enforces that. +const mcpServerUrl = new URL(`http://127.0.0.1:${PORT}/mcp`); +const authServerUrl = new URL(`http://127.0.0.1:${AUTH_PORT}/`); + +// Demo confidential client. DEMO ONLY — never hard-code real credentials. +export const DEMO_CLIENT = { clientId: 'demo-m2m-client', clientSecret: 'demo-m2m-secret', allowedScopes: ['mcp:tools', 'mcp:read'] }; + +// ---- Authorization Server (client_credentials only) ---- +const as = createClientCredentialsAuthServer({ authServerUrl, clients: [DEMO_CLIENT] }); +as.app.listen(AUTH_PORT, () => console.error(`[auth-server] client_credentials AS on ${authServerUrl.href}`)); + +// ---- Resource Server (MCP) ---- +const handler = createMcpHandler(ctx => { + const server = new McpServer({ name: 'oauth-client-credentials-example', version: '1.0.0' }); + server.registerTool( + 'whoami', + { description: 'Returns the authenticated client and its granted scopes.', inputSchema: z.object({}) }, + async () => ({ + content: [{ type: 'text', text: JSON.stringify({ clientId: ctx.authInfo?.clientId, scopes: ctx.authInfo?.scopes }) }] + }) + ); + return server; +}); + +const app = createMcpExpressApp(); +app.use( + mcpAuthMetadataRouter({ + oauthMetadata: as.metadata, + resourceServerUrl: mcpServerUrl, + scopesSupported: ['mcp:tools', 'mcp:read'], + resourceName: 'oauth-client-credentials example' + }) +); +const auth = requireBearerAuth({ + verifier: as.verifier, + requiredScopes: ['mcp:tools'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); +// `requireBearerAuth` sets `req.auth`; `handler.node` reads it and passes it +// to the factory as `ctx.authInfo`. +app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body)); + +app.listen(PORT, () => console.error(`[resource-server] MCP on ${mcpServerUrl.href}`)); diff --git a/examples/oauth/README.md b/examples/oauth/README.md new file mode 100644 index 0000000000..3ec20ce3c9 --- /dev/null +++ b/examples/oauth/README.md @@ -0,0 +1,28 @@ +# oauth + +The **authorization-code** OAuth grant — the interactive "user signs in and approves" flow — against an in-repo OAuth-protected MCP server. + +- `server.ts` — `setupAuthServer` (the better-auth/OIDC demo Authorization Server from `@mcp-examples/shared`) on `:PORT+1`, and a `createMcpHandler` Resource Server behind `requireBearerAuth({ verifier: demoTokenVerifier })` on `:PORT/mcp`, advertising the AS via + `createProtectedResourceMetadataRouter` (RFC 9728). DEMO ONLY — the AS auto-signs-in a fixed user, and with `OAUTH_DEMO_AUTO_CONSENT=1` it also auto-approves the consent screen. +- `client.ts` — **CI-runnable headless flow.** Drives the same SDK auth machinery as the browser client, but instead of `open()`ing the authorization URL it follows the 302 chain itself with `fetch(..., { redirect: 'manual' })` (the demo AS's auto-sign-in + auto-consent collapse + every interactive step into a redirect), reads the `code` off the final `Location` header, calls `transport.finishAuth(code)`, reconnects, and asserts `ctx.authInfo` round-trips. This is what the harness runs. +- `simpleOAuthClient.ts` + `simpleOAuthClientProvider.ts` — **manual real-browser flow.** Full authorization-code flow against any OAuth-protected MCP server: opens the browser, runs a local callback server on `:8090`, exchanges the code, then drops into a small `list`/`call` + REPL. Run this when you want to see the consent page. +- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`. +- `simpleTokenProvider.ts` — the minimal `AuthProvider` (just `token()`) for externally-managed bearer tokens. + +## Run it + +```bash +# headless (what CI does) — terminal 1: AS (:3001) + protected RS (:3000/mcp), auto-consent on +OAUTH_DEMO_AUTO_CONSENT=1 pnpm --filter @mcp-examples/oauth server +# terminal 2: follows the 302 chain, exchanges the code, asserts whoami +pnpm --filter @mcp-examples/oauth client -- --http http://127.0.0.1:3000/mcp + +# manual real-browser flow — terminal 1: same server (auto-consent optional) +pnpm --filter @mcp-examples/oauth server +# terminal 2: opens a browser to the demo AS, callback server on :8090, then a list/call REPL +pnpm --filter @mcp-examples/oauth client:browser +``` + +For the headless bearer-token resource-server case see `../bearer-auth/`; for the machine-to-machine `client_credentials` grant see `../oauth-client-credentials/`; for URL-mode elicitation see `../elicitation/`; for the interactive readline playground see `../repl/`. diff --git a/examples/oauth/client.ts b/examples/oauth/client.ts new file mode 100644 index 0000000000..8486d9e6ea --- /dev/null +++ b/examples/oauth/client.ts @@ -0,0 +1,151 @@ +/** + * Self-verifying **authorization-code** OAuth client — the CI-runnable headless + * twin of {@link ./simpleOAuthClient.ts}. + * + * `simpleOAuthClient.ts` is the manual real-user example: it `open()`s the + * authorization URL in a real browser, the user signs in and clicks **Approve** + * on the consent screen, the browser is redirected to a local callback server, + * and the example reads the `code` off that callback. THIS file drives the + * exact same SDK auth machinery but follows the redirect chain itself — which + * only works because the demo Authorization Server is started with + * `OAUTH_DEMO_AUTO_CONSENT=1` so its `/sign-in` page auto-signs-in a fixed + * demo user and its `/authorize` endpoint auto-consents (skips the Approve + * screen and 302s straight back to `redirect_uri?code=...`). + * + * Flow: + * 1. Connect with an {@linkcode InMemoryOAuthClientProvider} → 401 → SDK auth + * driver discovers PRM → AS metadata → registers a client (DCR) → builds + * the authorization URL → calls our `redirectToAuthorization` hook (we + * capture the URL) → throws {@linkcode UnauthorizedError}. + * 2. Follow that URL with `fetch(..., { redirect: 'manual' })`, forwarding + * `Set-Cookie` → `Cookie` across hops, until the AS 302s to our + * `redirect_uri` with `?code=...`. No callback server is bound — the code + * is read straight off the `Location` header. + * 3. `transport.finishAuth(code)` → SDK exchanges the code (+ PKCE verifier) + * for tokens at the AS `/token` endpoint and saves them on the provider. + * 4. Reconnect with a fresh transport (same provider, now holding tokens) → + * Bearer header → 200. Call `whoami` and assert `ctx.authInfo` round-trips. + */ +import type { OAuthClientMetadata } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; +import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; + +const URL_ARG = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); + +// The redirect target the AS will 302 back to with `?code=...`. In the real +// browser flow (`simpleOAuthClient.ts`) a tiny HTTP server listens here so the +// browser has somewhere to land; headlessly we never bind it — we read the +// `code` off the final 302's `Location` header instead. +const CALLBACK_URL = 'http://127.0.0.1:8090/callback'; + +/** + * Follow an authorization URL through the demo AS's redirect chain + * (authorize → /sign-in → authorize → redirect_uri?code=...) and return the + * `code`. This is the headless stand-in for "the user's browser navigates the + * login + consent pages": cookies are forwarded hop-to-hop the way a browser + * would, and the demo AS's auto-sign-in + `autoConsent` collapse every + * interactive step into a 302. + */ +async function followAuthorizationRedirects(authorizationUrl: URL): Promise { + let next = authorizationUrl.href; + // Crude cookie jar — enough for a single-origin demo AS. + const jar = new Map(); + for (let hop = 0; hop < 10; hop++) { + const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; '); + // In a real client this is `open(authorizationUrl)` — we follow the redirect + // chain headlessly because the demo AS auto-signs-in and auto-approves. + const res = await fetch(next, { redirect: 'manual', headers: cookie ? { cookie } : {} }); + for (const sc of res.headers.getSetCookie()) { + const pair = sc.split(';', 1)[0] ?? ''; + const eq = pair.indexOf('='); + if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + const location = res.headers.get('location'); + if (!location || res.status < 300 || res.status >= 400) { + const body = await res.text().catch(() => ''); + throw new Error(`expected a redirect at hop ${hop} (${next}); got ${res.status}\n${body.slice(0, 400)}`); + } + const resolved = new globalThis.URL(location, next); + // In a real deployment, the browser would render the consent page here and + // the user would click Approve; the demo AS's `autoConsent` flag simulates + // that approval, so the chain ends in a 302 straight to `redirect_uri`. + if (resolved.href.startsWith(CALLBACK_URL)) { + const code = resolved.searchParams.get('code'); + const error = resolved.searchParams.get('error'); + if (error) throw new Error(`AS returned error on callback: ${error} ${resolved.searchParams.get('error_description') ?? ''}`); + if (!code) throw new Error(`callback redirect missing ?code: ${resolved.href}`); + return code; + } + next = resolved.href; + } + throw new Error('authorization redirect chain did not terminate at the callback within 10 hops'); +} + +runClient('oauth', async () => { + // ---- 1. Kick off the SDK auth driver -------------------------------------- + // The SDK builds the authorization URL and hands it to + // `redirectToAuthorization` — in `simpleOAuthClient.ts` that opens a browser; + // here we just capture it. + let capturedAuthorizationUrl: URL | undefined; + const clientMetadata: OAuthClientMetadata = { + client_name: 'Headless OAuth MCP Client (CI)', + redirect_uris: [CALLBACK_URL], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post' + }; + const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => { + capturedAuthorizationUrl = url; + }); + + const client = new Client({ name: 'oauth-headless-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); + const firstTransport = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); + let challenged = false; + try { + await client.connect(firstTransport); + } catch (error) { + // Under `--legacy` the transport surfaces `UnauthorizedError` directly; + // under `mode: 'auto'` the version-negotiation probe is what got 401'd + // and wraps it in an EraNegotiationFailed `SdkError` whose `data.cause` + // is the original `UnauthorizedError`. Either way the auth driver has + // already run by the time we land here — DCR done, auth URL captured. + const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; + if (!(root instanceof UnauthorizedError)) throw error; + challenged = true; + } + check.ok(challenged, 'first connect must 401 and throw UnauthorizedError'); + check.ok(capturedAuthorizationUrl, 'SDK auth driver should have produced an authorization URL'); + check.ok(provider.clientInformation()?.client_id, 'dynamic client registration should have run'); + + // ---- 2. Follow the authorization URL headlessly --------------------------- + // (the browser-and-user stand-in; see `followAuthorizationRedirects`). + const code = await followAuthorizationRedirects(capturedAuthorizationUrl!); + + // ---- 3. Exchange the code for tokens -------------------------------------- + // In the browser flow the local callback server hands this `code` to + // `transport.finishAuth`; we read it off the `Location` header instead. The + // SDK now POSTs `grant_type=authorization_code` (+ PKCE `code_verifier`) to + // the AS `/token` endpoint and saves the tokens on `provider`. + await firstTransport.finishAuth(code); + const tokens = provider.tokens(); + check.ok(tokens?.access_token, 'token exchange should have yielded an access_token'); + check.equal(tokens?.token_type, 'Bearer'); + + // ---- 4. Reconnect with the now-populated provider ------------------------- + // A fresh transport reads the saved Bearer token from `provider` and the + // protected `/mcp` endpoint lets us through. + const transport = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); + await client.connect(transport); + + const result = await client.callTool({ name: 'whoami', arguments: {} }); + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''; + const seen = JSON.parse(text) as { clientId?: string; scopes?: string[] }; + // `ctx.authInfo` round-trips: the clientId the AS minted at DCR time is the + // one the Resource Server's verifier sees on the Bearer token. + check.equal(seen.clientId, provider.clientInformation()?.client_id, 'ctx.authInfo.clientId round-trips the DCR client_id'); + check.ok(seen.scopes?.includes('openid'), 'ctx.authInfo.scopes carries a granted scope'); + + await client.close(); +}); diff --git a/examples/client/src/dualModeAuth.ts b/examples/oauth/dualModeAuth.ts similarity index 99% rename from examples/client/src/dualModeAuth.ts rename to examples/oauth/dualModeAuth.ts index 4dd1eaded4..ac4dcc1e82 100644 --- a/examples/client/src/dualModeAuth.ts +++ b/examples/oauth/dualModeAuth.ts @@ -79,7 +79,7 @@ async function connectAndList(transport: StreamableHTTPClientTransport): Promise // --- Driver ---------------------------------------------------------------- async function main() { - const serverUrl = new URL(process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'); + const serverUrl = new URL(process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp'); const mode = process.argv[2] || 'host'; let transport: StreamableHTTPClientTransport; diff --git a/examples/oauth/package.json b/examples/oauth/package.json new file mode 100644 index 0000000000..ab1fc2ea1f --- /dev/null +++ b/examples/oauth/package.json @@ -0,0 +1,34 @@ +{ + "name": "@mcp-examples/oauth", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts", + "client:browser": "tsx simpleOAuthClient.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "cors": "catalog:runtimeServerOnly", + "open": "^11.0.0", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@types/cors": "catalog:devTools", + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "env": { + "OAUTH_DEMO_AUTO_CONSENT": "1" + }, + "//": "client.ts drives the full authorization-code flow headlessly because OAUTH_DEMO_AUTO_CONSENT=1 makes the demo AS auto-sign-in + auto-approve, collapsing the browser dance into a 302 chain. simpleOAuthClient.ts is the manual real-browser flow — run via `pnpm client:browser`." + } +} diff --git a/examples/oauth/server.ts b/examples/oauth/server.ts new file mode 100644 index 0000000000..cf380a39f2 --- /dev/null +++ b/examples/oauth/server.ts @@ -0,0 +1,82 @@ +/** + * In-repo OAuth-protected MCP server for the **authorization-code** flow — the + * demo Resource Server that {@link ./client.ts} (headless, CI) and + * {@link ./simpleOAuthClient.ts} (manual, real browser) authenticate against. + * + * One process, two listeners on adjacent ports: + * + * - `:PORT+1` — the demo **Authorization Server** (`setupAuthServer` from + * `@mcp-examples/shared`, backed by better-auth's OIDC plugin). It + * implements the `authorization_code` grant only and auto-signs-in a fixed + * demo user. With `OAUTH_DEMO_AUTO_CONSENT=1` it also **auto-consents** — + * the `/authorize` endpoint skips the consent UI and 302s straight back to + * `redirect_uri?code=...`, so the whole browser dance becomes a chain of + * redirects a headless client can follow. + * - `:PORT` — the MCP **Resource Server**: `createMcpHandler` behind + * `requireBearerAuth({ verifier: demoTokenVerifier })`, advertising the AS + * via `createProtectedResourceMetadataRouter` (RFC 9728) so the client's + * discovery from a `401` `WWW-Authenticate` challenge works. + * + * DEMO ONLY — NOT FOR PRODUCTION. The demo AS auto-approves a fixed user; CORS + * allows every origin; tokens are validated in-process against the same demo + * AS instance. + */ +import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared'; +import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import * as z from 'zod/v4'; + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const MCP_PORT = portIdx === -1 ? Number(process.env.MCP_PORT ?? 3000) : Number(argv[portIdx + 1]); +const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : MCP_PORT + 1; +// 127.0.0.1 (not `localhost`) so the PRM `resource` value matches the URL the +// harness passes the client byte-for-byte — the SDK auth driver enforces that. +const mcpServerUrl = new URL(`http://127.0.0.1:${MCP_PORT}/mcp`); +const authServerUrl = new URL(`http://127.0.0.1:${AUTH_PORT}`); + +// ---- Authorization Server (better-auth OIDC; authorization_code only) ---- +// `autoConsent` is the demo-only switch that turns the consent screen into an +// immediate 302 — set by the harness so `./client.ts` can run without a browser. +setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, autoConsent: process.env.OAUTH_DEMO_AUTO_CONSENT === '1' }); + +// ---- Resource Server (MCP) ---- +const handler = createMcpHandler(ctx => { + const server = new McpServer({ name: 'oauth-protected-example', version: '1.0.0' }); + server.registerTool( + 'whoami', + { description: 'Returns the authenticated subject and granted scopes.', inputSchema: z.object({}) }, + async () => ({ + content: [{ type: 'text', text: JSON.stringify({ clientId: ctx.authInfo?.clientId, scopes: ctx.authInfo?.scopes }) }] + }) + ); + return server; +}); + +const app = createMcpExpressApp(); +// DEMO ONLY — restrict `origin` in production. `exposedHeaders` lists the +// response headers a browser-based MCP client must be able to read. +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', 'Last-Event-Id', 'Mcp-Protocol-Version'] + }) +); +// RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource/mcp +// — the client discovers the AS from the 401 challenge → this route → AS metadata. +app.use(createProtectedResourceMetadataRouter('/mcp')); + +const auth = requireBearerAuth({ + verifier: demoTokenVerifier, + requiredScopes: [], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); +// `requireBearerAuth` sets `req.auth`; `handler.node` reads it and passes it +// to the factory as `ctx.authInfo`. +app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body)); + +app.listen(MCP_PORT, () => { + console.error(`OAuth-protected MCP server listening on ${mcpServerUrl.href}`); + console.error(` Protected Resource Metadata: http://127.0.0.1:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); +}); diff --git a/examples/client/src/simpleOAuthClient.ts b/examples/oauth/simpleOAuthClient.ts similarity index 97% rename from examples/client/src/simpleOAuthClient.ts rename to examples/oauth/simpleOAuthClient.ts index 1187f8ec1a..8ce6798608 100644 --- a/examples/client/src/simpleOAuthClient.ts +++ b/examples/oauth/simpleOAuthClient.ts @@ -11,10 +11,15 @@ import open from 'open'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; // Configuration -const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp'; +const DEFAULT_SERVER_URL = 'http://127.0.0.1:3000/mcp'; const CALLBACK_PORT = 8090; // Use different port than auth server (3001) const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; +/** Minimal HTML escaper for any user/query-derived value interpolated into an HTML response. */ +function escHtml(s: string): string { + return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + /** * Interactive MCP client with OAuth authentication * Demonstrates the complete OAuth flow with browser-based authorization @@ -109,7 +114,7 @@ class InteractiveOAuthClient {

Authorization Failed

-

Error: ${error}

+

Error: ${escHtml(error)}

`); diff --git a/examples/client/src/simpleOAuthClientProvider.ts b/examples/oauth/simpleOAuthClientProvider.ts similarity index 100% rename from examples/client/src/simpleOAuthClientProvider.ts rename to examples/oauth/simpleOAuthClientProvider.ts diff --git a/examples/client/src/simpleTokenProvider.ts b/examples/oauth/simpleTokenProvider.ts similarity index 95% rename from examples/client/src/simpleTokenProvider.ts rename to examples/oauth/simpleTokenProvider.ts index ce68fde5a1..f8996760a6 100644 --- a/examples/client/src/simpleTokenProvider.ts +++ b/examples/oauth/simpleTokenProvider.ts @@ -11,14 +11,14 @@ * providers which implement both `token()` and `onUnauthorized()`. * * Environment variables: - * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) + * MCP_SERVER_URL - Server URL (default: http://127.0.0.1:3000/mcp) * MCP_TOKEN - Bearer token to use for authentication (required) */ import type { AuthProvider } from '@modelcontextprotocol/client'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; +const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp'; async function main() { const token = process.env.MCP_TOKEN; diff --git a/examples/server/package.json b/examples/package.json similarity index 60% rename from examples/server/package.json rename to examples/package.json index fcff95d9a9..105f7669e5 100644 --- a/examples/server/package.json +++ b/examples/package.json @@ -1,8 +1,8 @@ { - "name": "@modelcontextprotocol/examples-server", + "name": "@modelcontextprotocol/examples", "private": true, "version": "2.0.0-alpha.0", - "description": "Model Context Protocol implementation for TypeScript", + "description": "Runnable MCP TypeScript SDK examples — one story per directory, each a self-verifying client/server pair", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", @@ -15,44 +15,35 @@ "engines": { "node": ">=20" }, - "keywords": [ - "modelcontextprotocol", - "mcp" - ], "scripts": { "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build:esm && pnpm run build:cjs", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "start": "pnpm run server", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" + "lint": "eslint . && prettier --ignore-path ../.prettierignore --check .", + "lint:fix": "eslint . --fix && prettier --ignore-path ../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint" }, "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", - "@modelcontextprotocol/examples-shared": "workspace:^", + "@modelcontextprotocol/client": "workspace:^", + "@mcp-examples/shared": "workspace:^", "@modelcontextprotocol/express": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", "@modelcontextprotocol/node": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@valibot/to-json-schema": "catalog:devTools", + "ajv": "catalog:runtimeShared", "arktype": "catalog:devTools", - "better-auth": "^1.4.17", "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", "hono": "catalog:runtimeServerOnly", + "open": "^11.0.0", "valibot": "catalog:devTools", "zod": "catalog:runtimeShared" }, "devDependencies": { "@modelcontextprotocol/eslint-config": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", "@types/cors": "catalog:devTools", "@types/express": "catalog:devTools", - "tsdown": "catalog:devTools" + "tsx": "catalog:devTools" } } diff --git a/examples/parallel-calls/README.md b/examples/parallel-calls/README.md new file mode 100644 index 0000000000..aed18429c1 --- /dev/null +++ b/examples/parallel-calls/README.md @@ -0,0 +1,5 @@ +# parallel-calls + +Multiple clients connecting to one endpoint in parallel, and one client making parallel `callTool()` calls — with per-call logging notifications attributed back to their caller. + +Over HTTP every client connects to the one running endpoint; over stdio each client spawns its own server process (so the "one client / parallel calls" leg is the per-call attribution test on either transport). diff --git a/examples/parallel-calls/client.ts b/examples/parallel-calls/client.ts new file mode 100644 index 0000000000..2051a38e6a --- /dev/null +++ b/examples/parallel-calls/client.ts @@ -0,0 +1,49 @@ +/** + * Two clients in parallel, each calling the notification-emitting tool, and + * one client making two parallel tool calls — asserts every result returns + * and that notifications were attributed back to the right caller. + * + * Over HTTP every client connects to the one running endpoint; over stdio + * each `connectFromArgs` spawns its own server process (so the + * "multiple clients" leg is per-process, while the "one client / parallel + * calls" leg exercises one server's per-call attribution either way). + */ +import type { Client } from '@modelcontextprotocol/client'; + +import { check, connectFromArgs, runClient } from '../harness.js'; + +async function makeClient(): Promise<{ client: Client; notifications: string[] }> { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname); + const notifications: string[] = []; + client.setNotificationHandler('notifications/message', n => { + notifications.push(String(n.params.data)); + }); + return { client, notifications }; +} + +runClient('parallel-calls', async () => { + // --- multiple clients, one call each --- + const [a, b] = await Promise.all([makeClient(), makeClient()]); + const [ra, rb] = await Promise.all([ + a.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'A', count: 3 } }), + b.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'B', count: 3 } }) + ]); + check.match(ra.content?.[0]?.type === 'text' ? ra.content[0].text : '', /\[A\] done/); + check.match(rb.content?.[0]?.type === 'text' ? rb.content[0].text : '', /\[B\] done/); + check.ok(a.notifications.every(m => m.includes('[A]'))); + check.ok(b.notifications.every(m => m.includes('[B]'))); + check.ok(a.notifications.length >= 3 && b.notifications.length >= 3); + await a.client.close(); + await b.client.close(); + + // --- one client, parallel tool calls --- + const c = await makeClient(); + const results = await Promise.all([ + c.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'C1', count: 2 } }), + c.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'C2', count: 2 } }) + ]); + check.equal(results.length, 2); + check.ok(c.notifications.some(m => m.includes('[C1]')) && c.notifications.some(m => m.includes('[C2]'))); + await c.client.close(); +}); diff --git a/examples/parallel-calls/package.json b/examples/parallel-calls/package.json new file mode 100644 index 0000000000..3047392f58 --- /dev/null +++ b/examples/parallel-calls/package.json @@ -0,0 +1,17 @@ +{ + "name": "@mcp-examples/parallel-calls", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/parallel-calls/server.ts b/examples/parallel-calls/server.ts new file mode 100644 index 0000000000..2f11b79ab1 --- /dev/null +++ b/examples/parallel-calls/server.ts @@ -0,0 +1,37 @@ +/** + * One notification-emitting tool that the parallel-calls client drives with + * multiple concurrent clients (HTTP) or one client / multiple concurrent + * calls (both transports), asserting in-flight notifications are attributed + * back to the right caller. One binary, either transport. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'parallel-calls-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + server.registerTool( + 'start-notification-stream', + { + description: 'Sends a few periodic logging notifications tagged with the caller id', + inputSchema: z.object({ caller: z.string(), count: z.number().int().min(1).max(20).default(3) }) + }, + async ({ caller, count }, ctx) => { + for (let i = 1; i <= count; i++) { + // Send as a request-tied notification so it rides the same SSE + // stream as the eventual result. + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { level: 'info', data: `[${caller}] tick ${i}/${count}` } + }); + await new Promise(r => setTimeout(r, 20)); + } + return { content: [{ type: 'text', text: `[${caller}] done (${count})` }] }; + } + ); + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/prompts/README.md b/examples/prompts/README.md new file mode 100644 index 0000000000..fe42008587 --- /dev/null +++ b/examples/prompts/README.md @@ -0,0 +1,7 @@ +# prompts + +Register prompts with `McpServer.registerPrompt`; wrap argument schemas with `completable(...)` for autocompletion. The client lists prompts, completes the `language` argument, and renders one with `getPrompt()`. + +```bash +pnpm tsx examples/prompts/client.ts +``` diff --git a/examples/prompts/client.ts b/examples/prompts/client.ts new file mode 100644 index 0000000000..99a0ba95e5 --- /dev/null +++ b/examples/prompts/client.ts @@ -0,0 +1,25 @@ +/** + * Drives the prompts example: list, complete an argument, get a prompt. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('prompts', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname); + + const list = await client.listPrompts(); + check.ok(list.prompts.some(p => p.name === 'review-code')); + + const completion = await client.complete({ + ref: { type: 'ref/prompt', name: 'review-code' }, + argument: { name: 'language', value: 'ty' } + }); + check.ok(completion.completion.values.includes('typescript')); + + const got = await client.getPrompt({ name: 'review-code', arguments: { language: 'rust', code: 'fn main() {}' } }); + check.equal(got.messages.length, 1); + const text = got.messages[0]?.content.type === 'text' ? got.messages[0].content.text : ''; + check.match(text, /Review this rust code/); + + await client.close(); +}); diff --git a/examples/prompts/package.json b/examples/prompts/package.json new file mode 100644 index 0000000000..148c977c15 --- /dev/null +++ b/examples/prompts/package.json @@ -0,0 +1,16 @@ +{ + "name": "@mcp-examples/prompts", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/prompts/server.ts b/examples/prompts/server.ts new file mode 100644 index 0000000000..856b953cf5 --- /dev/null +++ b/examples/prompts/server.ts @@ -0,0 +1,42 @@ +/** + * Prompts primitive + completion. + * + * Register prompts with `McpServer.registerPrompt`; wrap an arg schema with + * `completable(...)` so the client's `complete()` call returns suggestions. + * One binary, either transport. + */ +import { completable, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +const LANGUAGES = ['python', 'typescript', 'rust', 'go']; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'prompts-example', version: '1.0.0' }); + + server.registerPrompt( + 'review-code', + { + title: 'Code review', + description: 'Review code for quality and idioms', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => LANGUAGES.filter(l => l.startsWith(value))), + code: z.string().describe('The code to review') + }) + }, + async ({ language, code }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `Review this ${language} code for quality and idioms:\n\n${code}` } + } + ] + }) + ); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/repl/README.md b/examples/repl/README.md new file mode 100644 index 0000000000..4a6dbf808a --- /dev/null +++ b/examples/repl/README.md @@ -0,0 +1,13 @@ +# repl (excluded) + +The interactive playground. A fully-featured **sessionful** HTTP server (tools with input/output schemas + annotations, prompts with completion, direct + templated resources, `notifications/message` logging, `resources/list_changed`, in-memory `eventStore` for resumability) +paired with a readline REPL client that can drive every primitive by hand — `list-tools`, `call-tool`, `list-prompts`, `get-prompt`, `list-resources`, `read-resource`, form elicitation, resumable notification streams (`reconnect`, `run-notifications-tool-with-resumability`). + +Excluded from the harness (`package.json#example.excluded`); run manually: + +```sh +pnpm run server # terminal 1 — listens on http://localhost:3000/mcp +pnpm run client # terminal 2 — readline REPL +``` + +Try `multi-greet Ada`, `collect-info contact`, `call-tool add-resource {"name":"n1","text":"hello"}` then `list-resources`, or `start-notifications 500 5`. diff --git a/examples/client/src/simpleStreamableHttp.ts b/examples/repl/client.ts similarity index 100% rename from examples/client/src/simpleStreamableHttp.ts rename to examples/repl/client.ts diff --git a/examples/repl/package.json b/examples/repl/package.json new file mode 100644 index 0000000000..01c68d4d19 --- /dev/null +++ b/examples/repl/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mcp-examples/repl", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "ajv": "catalog:runtimeShared", + "express": "catalog:runtimeServerOnly", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@types/express": "catalog:devTools", + "tsx": "catalog:devTools" + }, + "example": { + "excluded": "interactive REPL — run manually" + } +} diff --git a/examples/repl/server.ts b/examples/repl/server.ts new file mode 100644 index 0000000000..cf6c41de84 --- /dev/null +++ b/examples/repl/server.ts @@ -0,0 +1,285 @@ +/** + * Fully-featured **sessionful** HTTP playground server for the interactive + * REPL client. + * + * Exposes every primitive the REPL client (`./client.ts`) can drive: tools + * (typed input/output schemas + annotations + form elicitation + + * `ResourceLink`s), prompts (with `completable()` argument completion), + * resources (direct + `ResourceTemplate`), `notifications/message` logging, + * and `notifications/resources/list_changed`. + * + * Hosted on `NodeStreamableHTTPServerTransport` with an in-memory + * `eventStore` so the REPL client's `reconnect` and + * `run-notifications-tool-with-resumability` commands actually replay missed + * events on reconnect with `Last-Event-ID`. + * + * HTTP-only — pair with `pnpm run client` in a second terminal. + */ +import { randomUUID } from 'node:crypto'; + +import { InMemoryEventStore } from '@mcp-examples/shared'; +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '@modelcontextprotocol/server'; +import { completable, isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; + +/** Dynamic resources added via the `add-resource` tool (shared across sessions). */ +const dynamicResources = new Map(); + +function buildServer(): McpServer { + const server = new McpServer( + { + name: 'repl-playground-server', + version: '1.0.0', + icons: [{ src: 'https://modelcontextprotocol.io/favicon.svg', sizes: ['any'], mimeType: 'image/svg+xml' }], + websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk' + }, + { capabilities: { logging: {}, resources: { listChanged: true } } } + ); + + // --- Tools ------------------------------------------------------------- + + // Typed input + inferred structured output + read-only annotation. + server.registerTool( + 'greet', + { + title: 'Greeting Tool', + description: 'Returns a greeting for the named subject', + inputSchema: z.object({ name: z.string().describe('Name to greet') }), + outputSchema: z.object({ greeting: z.string() }), + annotations: { readOnlyHint: true, idempotentHint: true } + }, + async ({ name }) => { + const structuredContent = { greeting: `Hello, ${name}!` }; + return { content: [{ type: 'text', text: structuredContent.greeting }], structuredContent }; + } + ); + + // Sends `notifications/message` log lines while it runs (drive with `multi-greet`). + server.registerTool( + 'multi-greet', + { + description: 'Sends several greetings with a delay between each, emitting log notifications as it goes', + inputSchema: z.object({ name: z.string().describe('Name to greet') }), + annotations: { title: 'Multiple Greeting Tool', readOnlyHint: true, openWorldHint: false } + }, + async ({ name }, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); + await sleep(500); + await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); + await sleep(500); + await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); + return { content: [{ type: 'text', text: `Good morning, ${name}!` }] }; + } + ); + + // Form-mode elicitation (drive with the REPL's `collect-info` command). + server.registerTool( + 'collect-user-info', + { + description: 'Collects user information through form elicitation', + inputSchema: z.object({ + infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') + }) + }, + async ({ infoType }, ctx): Promise => { + const schemas: Record< + string, + { message: string; schema: { type: 'object'; properties: Record; required?: string[] } } + > = { + contact: { + message: 'Please provide your contact information', + schema: { + type: 'object', + properties: { + name: { type: 'string', title: 'Full Name' }, + email: { type: 'string', title: 'Email Address', format: 'email' } + }, + required: ['name', 'email'] + } + }, + preferences: { + message: 'Please set your preferences', + schema: { + type: 'object', + properties: { + theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] }, + notifications: { type: 'boolean', title: 'Enable Notifications', default: true } + }, + required: ['theme'] + } + }, + feedback: { + message: 'Please provide your feedback', + schema: { + type: 'object', + properties: { + rating: { type: 'integer', title: 'Rating', minimum: 1, maximum: 5 }, + comments: { type: 'string', title: 'Comments', maxLength: 500 } + }, + required: ['rating'] + } + } + }; + const picked = schemas[infoType]!; + const result = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { mode: 'form', message: picked.message, requestedSchema: picked.schema } + }); + if (result.action === 'accept') { + return { content: [{ type: 'text', text: `Collected ${infoType}: ${JSON.stringify(result.content, null, 2)}` }] }; + } + return { + content: [{ type: 'text', text: `User ${result.action === 'decline' ? 'declined' : 'cancelled'} the ${infoType} request.` }] + }; + } + ); + + // Periodic notifications for testing resumability (`start-notifications` in the REPL). + server.registerTool( + 'start-notification-stream', + { + description: 'Sends periodic log notifications for testing resumability', + inputSchema: z.object({ + interval: z.number().describe('Interval in ms between notifications').default(1000), + count: z.number().describe('Number of notifications to send').default(5) + }) + }, + async ({ interval, count }, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + for (let i = 1; i <= count; i++) { + await ctx.mcpReq.log('info', `Periodic notification #${i} at ${new Date().toISOString()}`); + await sleep(interval); + } + return { content: [{ type: 'text', text: `Sent ${count} notifications at ${interval}ms intervals` }] }; + } + ); + + // Mutates the resource set and publishes `resources/list_changed` on this + // session's standalone SSE stream. + server.registerTool( + 'add-resource', + { + description: 'Add a dynamic note resource and publish resources/list_changed', + inputSchema: z.object({ name: z.string(), text: z.string() }), + annotations: { destructiveHint: false } + }, + async ({ name, text }): Promise => { + dynamicResources.set(name, text); + server.sendResourceListChanged(); + return { content: [{ type: 'text', text: `Added note://${name}` }] }; + } + ); + + // Returns ResourceLinks (drive with `call-tool list-files`, then `read-resource `). + server.registerTool( + 'list-files', + { + title: 'List Files with ResourceLinks', + description: 'Returns a list of files as ResourceLinks without embedding their content', + inputSchema: z.object({}) + }, + async (): Promise => { + const links: ResourceLink[] = [ + { type: 'resource_link', uri: 'config://app', name: 'App config', mimeType: 'application/json' }, + ...[...dynamicResources.keys()].map( + (name): ResourceLink => ({ type: 'resource_link', uri: `note://${name}`, name, mimeType: 'text/plain' }) + ) + ]; + return { content: [{ type: 'text', text: 'Available files:' }, ...links] }; + } + ); + + // --- Prompts (with argument completion) -------------------------------- + + const LANGUAGES = ['python', 'typescript', 'rust', 'go']; + server.registerPrompt( + 'greeting-template', + { + title: 'Greeting Template', + description: 'A simple greeting prompt template', + argsSchema: z.object({ + name: z.string().describe('Name to include in greeting'), + language: completable(z.string().describe('Language'), value => LANGUAGES.filter(l => l.startsWith(value))) + }) + }, + async ({ name, language }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Please greet ${name} in ${language}.` } }] + }) + ); + + // --- Resources (direct + template + dynamic) --------------------------- + + server.registerResource( + 'app-config', + 'config://app', + { title: 'App config', mimeType: 'application/json', description: 'Static application config' }, + async (uri): Promise => ({ + contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }] + }) + ); + + server.registerResource( + 'greeting', + new ResourceTemplate('greeting://{name}', { list: undefined }), + { description: 'A greeting for the named subject' }, + async (uri, vars): Promise => ({ contents: [{ uri: uri.href, text: `Hello, ${vars.name}!` }] }) + ); + + server.registerResource( + 'note', + new ResourceTemplate('note://{name}', { + list: () => ({ + resources: [...dynamicResources.keys()].map(name => ({ uri: `note://${name}`, name, mimeType: 'text/plain' })) + }) + }), + { description: 'A dynamic note added via add-resource', mimeType: 'text/plain' }, + async (uri, vars): Promise => { + const text = dynamicResources.get(String(vars.name)) ?? '(no such note)'; + return { contents: [{ uri: uri.href, mimeType: 'text/plain', text }] }; + } + ); + + return server; +} + +// Sessionful 2025-era hosting with an in-memory event store so the REPL +// client's resumability commands work (reconnect with `Last-Event-ID` replays +// missed `notifications/message` events). +const sessions = new Map(); +const eventStore = new InMemoryEventStore(); + +const app = createMcpExpressApp(); +app.all('/mcp', async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // resumability — events are persisted for replay on GET reconnect + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + await buildServer().connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); + } +}); + +app.listen(PORT, () => console.error(`[server] REPL playground listening on http://localhost:${PORT}/mcp`)); + +process.on('SIGINT', async () => { + for (const t of sessions.values()) await t.close(); + process.exit(0); +}); diff --git a/examples/resources/README.md b/examples/resources/README.md new file mode 100644 index 0000000000..409df6833c --- /dev/null +++ b/examples/resources/README.md @@ -0,0 +1,7 @@ +# resources + +Direct resources (a fixed URI string) and templated resources (`ResourceTemplate('greeting://{name}')`). The client lists both, reads the direct config, and reads a templated greeting. + +```bash +pnpm tsx examples/resources/client.ts +``` diff --git a/examples/resources/client.ts b/examples/resources/client.ts new file mode 100644 index 0000000000..0823db8345 --- /dev/null +++ b/examples/resources/client.ts @@ -0,0 +1,25 @@ +/** + * Drives the resources example: list, list templates, read direct + templated. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('resources', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname); + + const list = await client.listResources(); + check.ok(list.resources.some(r => r.uri === 'config://app')); + + const templates = await client.listResourceTemplates(); + check.ok(templates.resourceTemplates.some(t => t.uriTemplate === 'greeting://{name}')); + + const config = await client.readResource({ uri: 'config://app' }); + const configContent = config.contents[0]; + check.equal(configContent && 'text' in configContent ? configContent.text : '', '{"feature":true}'); + + const hello = await client.readResource({ uri: 'greeting://world' }); + const helloContent = hello.contents[0]; + check.equal(helloContent && 'text' in helloContent ? helloContent.text : '', 'Hello, world!'); + + await client.close(); +}); diff --git a/examples/resources/package.json b/examples/resources/package.json new file mode 100644 index 0000000000..4b907b0f00 --- /dev/null +++ b/examples/resources/package.json @@ -0,0 +1,15 @@ +{ + "name": "@mcp-examples/resources", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/resources/server.ts b/examples/resources/server.ts new file mode 100644 index 0000000000..12633739b7 --- /dev/null +++ b/examples/resources/server.ts @@ -0,0 +1,35 @@ +/** + * Resources primitive — direct + templated. + * + * `McpServer.registerResource` accepts either a fixed URI string (direct + * resource) or a `ResourceTemplate` (URI template with substitution). One + * binary, either transport. + */ +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'resources-example', version: '1.0.0' }); + + // A direct resource at a fixed URI. + server.registerResource( + 'app-config', + 'config://app', + { mimeType: 'application/json', description: 'Static application config' }, + async uri => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }] }) + ); + + // A templated resource: `greeting://{name}`. + server.registerResource( + 'greeting', + new ResourceTemplate('greeting://{name}', { list: undefined }), + { description: 'A greeting for the named subject' }, + async (uri, vars) => ({ contents: [{ uri: uri.href, text: `Hello, ${vars.name}!` }] }) + ); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/sampling/README.md b/examples/sampling/README.md new file mode 100644 index 0000000000..c2f9f8e2ff --- /dev/null +++ b/examples/sampling/README.md @@ -0,0 +1,19 @@ +# sampling + +A tool that asks the host LLM for a completion. One factory, both protocol eras: sampling works on both eras with different APIs — push-style on 2025, `inputRequired` on 2026; the protocol carries the `sampling/createMessage` request differently but the user experience is the +same. + +| 2025-era (`--legacy`, push-style) | 2026-07-28 (multi-round-trip) | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `await ctx.mcpReq.requestSampling({ messages, maxTokens })` — the server pushes a `sampling/createMessage` request and awaits the answer in-line | `return inputRequired({ inputRequests: { summary: inputRequired.createMessage({ messages, maxTokens }) } })` — the client fulfils the embedded request and retries with the response attached | + +The client registers **one** `sampling/createMessage` handler; on the 2026-07-28 leg the auto-fulfilment driver dispatches the embedded request to that same handler. + +> Push-style sampling is **deprecated** as of protocol revision 2026-07-28 (SEP-2577) but remains functional during the deprecation window. + +Runs the full transport × era matrix. + +```bash +pnpm --filter @mcp-examples/sampling client # 2026-07-28 (inputRequired) +pnpm --filter @mcp-examples/sampling client -- --legacy # 2025 (push-style) +``` diff --git a/examples/sampling/client.ts b/examples/sampling/client.ts new file mode 100644 index 0000000000..f5e986e6b0 --- /dev/null +++ b/examples/sampling/client.ts @@ -0,0 +1,29 @@ +/** + * Advertises the sampling capability, registers a `sampling/createMessage` + * handler that returns a canned summary, then calls the `summarize` tool and + * asserts the canned text round-tripped. + * + * The same handler serves both protocol eras: on the 2025-era leg + * (`--legacy`) the server pushes `sampling/createMessage` and this handler + * answers it directly; on the 2026-07-28 leg the auto-fulfilment driver + * dispatches the embedded `sampling/createMessage` from the server's + * `inputRequired` result to this same handler, then retries the tool call + * with the response attached. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('sampling', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname, { capabilities: { sampling: {} } }); + client.setRequestHandler('sampling/createMessage', async () => ({ + role: 'assistant', + content: { type: 'text', text: '[canned summary]' }, + model: 'stub', + stopReason: 'endTurn' + })); + + const result = await client.callTool({ name: 'summarize', arguments: { text: 'hello world' } }); + check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', '[canned summary]'); + + await client.close(); +}); diff --git a/examples/sampling/package.json b/examples/sampling/package.json new file mode 100644 index 0000000000..9baf2dac97 --- /dev/null +++ b/examples/sampling/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mcp-examples/sampling", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "dual", + "//": "2025-era push-style ctx.mcpReq.requestSampling runs over the harness's sessionful http/legacy arm; 2026-07-28 inputRequired.createMessage runs over the per-request modern arm. Full transport × era matrix." + } +} diff --git a/examples/sampling/server.ts b/examples/sampling/server.ts new file mode 100644 index 0000000000..614c4ca508 --- /dev/null +++ b/examples/sampling/server.ts @@ -0,0 +1,65 @@ +/** + * Sampling — a tool that asks the host LLM for a completion. One factory, + * both protocol eras. + * + * The same tool serves both eras with different APIs: on a 2025-era + * connection (`--legacy`, the `initialize` handshake) the server uses the + * push-style server→client request flow — `ctx.mcpReq.requestSampling(...)` + * sends `sampling/createMessage` and awaits the answer in-line. On a + * 2026-07-28 connection there is no server→client request channel: the same + * tool instead **returns** `inputRequired(...)` with an embedded + * `sampling/createMessage`, and the client retries with the model's response + * attached. The protocol carries the request differently; the user + * experience is the same. + * + * One binary, either transport. Logs go to stderr only — stdio's stdout is + * the JSON-RPC stream. + */ +import type { CallToolResult, InputRequiredResult, McpRequestContext } from '@modelcontextprotocol/server'; +import { inputRequired, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(reqCtx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'sampling-example', version: '1.0.0' }); + + server.registerTool( + 'summarize', + { description: 'Summarize text using the host LLM', inputSchema: z.object({ text: z.string() }) }, + async ({ text }, ctx): Promise => { + const messages = [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Please summarize the following text concisely:\n\n${text}` } + } + ]; + if (reqCtx.era === 'legacy') { + // 2025-era: push a server→client `sampling/createMessage` request + // and await the model's answer in-line. + const response = await ctx.mcpReq.requestSampling({ messages, maxTokens: 500 }); + // `content` is a single block when no tools were passed. + const content = response.content; + const summary = !Array.isArray(content) && content.type === 'text' ? content.text : 'Unable to generate summary'; + return { content: [{ type: 'text', text: summary }] }; + } + // 2026-07-28: return inputRequired with an embedded + // `sampling/createMessage` — the client's auto-fulfilment driver + // dispatches it to the same `sampling/createMessage` handler and + // retries this call with the model's response attached. + const response = ctx.mcpReq.inputResponses?.['summary'] as { content?: { type: string; text?: string } } | undefined; + if (!response) { + return inputRequired({ + inputRequests: { summary: inputRequired.createMessage({ messages, maxTokens: 500 }) } + }); + } + const summary = response.content?.type === 'text' ? (response.content.text ?? '') : 'Unable to generate summary'; + return { content: [{ type: 'text', text: summary }] }; + } + ); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/schema-validators/README.md b/examples/schema-validators/README.md new file mode 100644 index 0000000000..ea83e07a61 --- /dev/null +++ b/examples/schema-validators/README.md @@ -0,0 +1,7 @@ +# schema-validators + +Tool input/output schemas via Zod, ArkType and Valibot — any Standard-Schema-with-JSON library works. Also shows `outputSchema` → `structuredContent`. + +```bash +pnpm tsx examples/schema-validators/client.ts +``` diff --git a/examples/schema-validators/client.ts b/examples/schema-validators/client.ts new file mode 100644 index 0000000000..60bf559258 --- /dev/null +++ b/examples/schema-validators/client.ts @@ -0,0 +1,29 @@ +/** + * Calls each greet variant and asserts every inputSchema published as a JSON + * Schema with a required `name` string; calls `get-weather` and asserts the + * structured output matches. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('schema-validators', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname); + + const list = await client.listTools(); + for (const name of ['greet-zod', 'greet-arktype', 'greet-valibot']) { + const tool = list.tools.find(t => t.name === name); + check.ok(tool, `${name} should be listed`); + const required = (tool!.inputSchema as { required?: string[] }).required ?? []; + check.ok(required.includes('name'), `${name} inputSchema should require 'name'`); + const result = await client.callTool({ name, arguments: { name: 'world' } }); + check.match(result.content?.[0]?.type === 'text' ? result.content[0].text : '', /Hello, world!/); + } + + const weather = await client.callTool({ name: 'get-weather', arguments: { city: 'Tokyo' } }); + const sc = weather.structuredContent as { city?: string; conditions?: string; celsius?: number } | undefined; + check.equal(sc?.city, 'Tokyo'); + check.equal(sc?.conditions, 'sunny'); + check.equal(sc?.celsius, 21); + + await client.close(); +}); diff --git a/examples/schema-validators/package.json b/examples/schema-validators/package.json new file mode 100644 index 0000000000..e6eafa6cd6 --- /dev/null +++ b/examples/schema-validators/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mcp-examples/schema-validators", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "@valibot/to-json-schema": "catalog:devTools", + "arktype": "catalog:devTools", + "valibot": "catalog:devTools", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/schema-validators/server.ts b/examples/schema-validators/server.ts new file mode 100644 index 0000000000..bdca9c8c17 --- /dev/null +++ b/examples/schema-validators/server.ts @@ -0,0 +1,55 @@ +/** + * Tool input/output schemas via three Standard-Schema-compatible libraries + * (Zod, ArkType, Valibot) plus an `outputSchema` that emits + * `structuredContent`. The SDK accepts any Standard-Schema-with-JSON value; + * Valibot needs the `@valibot/to-json-schema` wrapper to expose JSON Schema + * conversion. One binary, either transport. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import { type } from 'arktype'; +import * as v from 'valibot'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'schema-validators-example', version: '1.0.0' }); + + server.registerTool( + 'greet-zod', + { description: 'Greet (Zod inputSchema)', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (zod)` }] }) + ); + + server.registerTool( + 'greet-arktype', + { description: 'Greet (ArkType inputSchema)', inputSchema: type({ name: 'string' }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (arktype)` }] }) + ); + + server.registerTool( + 'greet-valibot', + { description: 'Greet (Valibot inputSchema)', inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (valibot)` }] }) + ); + + // outputSchema → structuredContent. + server.registerTool( + 'get-weather', + { + description: 'Get (canned) weather information', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ city: z.string(), conditions: z.enum(['sunny', 'cloudy', 'rainy']), celsius: z.number() }) + }, + async ({ city }) => { + const structuredContent = { city, conditions: 'sunny' as const, celsius: 21 }; + return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent }; + } + ); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/server/README.md b/examples/server/README.md deleted file mode 100644 index bce265104a..0000000000 --- a/examples/server/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# MCP TypeScript SDK Examples (Server) - -This directory contains runnable MCP **server** examples built with `@modelcontextprotocol/server` plus framework adapters: - -- `@modelcontextprotocol/express` -- `@modelcontextprotocol/hono` - -For client examples, see [`../client/README.md`](../client/README.md). For guided docs, see [`../../docs/server.md`](../../docs/server.md). - -## Running examples - -From anywhere in the SDK: - -```bash -pnpm install -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts -``` - -Or, from within this package: - -```bash -cd examples/server -pnpm tsx src/simpleStreamableHttp.ts -``` - -## Example index - -| Scenario | Description | File | -| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) | -| Resource-Server-only auth | Minimal OAuth RS using `mcpAuthMetadataRouter` + `requireBearerAuth` from `@modelcontextprotocol/express` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) | -| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) | -| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) | -| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) | -| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) | -| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Sampling server | Demonstrates server-initiated sampling requests. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | -| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | -| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | -| Multi-round-trip server (2026-07-28) | Write-once tool that returns `inputRequired(...)` (form + URL elicitation, requestState echo) via `createMcpHandler`. | [`src/multiRoundTrip.ts`](src/multiRoundTrip.ts) | - -## OAuth demo flags (Streamable HTTP server) - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth -``` - -## URL elicitation example (server + client) - -Run the server: - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/elicitationUrlExample.ts -``` - -Run the client in another terminal: - -```bash -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts -``` - -## Multi-node deployment patterns - -When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases: - -- **Stateless mode** - no need to maintain state between calls. -- **Persistent storage mode** - state stored in a database; any node can handle a session. -- **Local state with message routing** - stateful nodes + pub/sub routing for a session. - -### Stateless mode - -To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with: - -```typescript -sessionIdGenerator: undefined; -``` - -``` -┌─────────────────────────────────────────────┐ -│ Client │ -└─────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────┐ -│ Load Balancer │ -└─────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ -│ MCP Server #1 │ │ MCP Server #2 │ -│ (Node.js) │ │ (Node.js) │ -└─────────────────┘ └─────────────────────┘ -``` - -### Persistent storage mode - -Configure the transport with session management, but use an external event store: - -```typescript -sessionIdGenerator: () => randomUUID(), -eventStore: databaseEventStore -``` - -``` -┌─────────────────────────────────────────────┐ -│ Client │ -└─────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────┐ -│ Load Balancer │ -└─────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ -│ MCP Server #1 │ │ MCP Server #2 │ -│ (Node.js) │ │ (Node.js) │ -└─────────────────┘ └─────────────────────┘ - │ │ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────┐ -│ Database (PostgreSQL) │ -│ │ -│ • Session state │ -│ • Event storage for resumability │ -└─────────────────────────────────────────────┘ -``` - -### Streamable HTTP with distributed message routing - -For scenarios where local in-memory state must be maintained on specific nodes, combine Streamable HTTP with pub/sub routing so one node can terminate the client connection while another node owns the session state. - -``` -┌─────────────────────────────────────────────┐ -│ Client │ -└─────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────┐ -│ Load Balancer │ -└─────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ -│ MCP Server #1 │◄───►│ MCP Server #2 │ -│ (Has Session A) │ │ (Has Session B) │ -└─────────────────┘ └─────────────────────┘ - ▲│ ▲│ - │▼ │▼ -┌─────────────────────────────────────────────┐ -│ Message Queue / Pub-Sub │ -│ │ -│ • Session ownership registry │ -│ • Bidirectional message routing │ -│ • Request/response forwarding │ -└─────────────────────────────────────────────┘ -``` - -## Backwards compatibility (Streamable HTTP ↔ legacy SSE) - -Start the server: - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts -``` - -Then run the backwards-compatible client: - -```bash -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/streamableHttpWithSseFallbackClient.ts -``` diff --git a/examples/server/eslint.config.mjs b/examples/server/eslint.config.mjs deleted file mode 100644 index 83b79879f6..0000000000 --- a/examples/server/eslint.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-check - -import baseConfig from '@modelcontextprotocol/eslint-config'; - -export default [ - ...baseConfig, - { - files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], - rules: { - // Allow console statements in examples only - 'no-console': 'off' - } - } -]; diff --git a/examples/server/src/arktypeExample.ts b/examples/server/src/arktypeExample.ts deleted file mode 100644 index 4a470532ed..0000000000 --- a/examples/server/src/arktypeExample.ts +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -/** - * Minimal MCP server using ArkType for schema validation. - * ArkType implements the Standard Schema spec with built-in JSON Schema conversion. - */ - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import { type } from 'arktype'; - -const server = new McpServer({ - name: 'arktype-example', - version: '1.0.0' -}); - -// Register a tool with ArkType schema -server.registerTool( - 'greet', - { - description: 'Generate a greeting', - inputSchema: type({ name: 'string' }) - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) -); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts deleted file mode 100644 index 6968a26e6c..0000000000 --- a/examples/server/src/customMethodExample.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Custom (non-spec) method example: a server that handles a vendor-prefixed - * `acme/search` request and emits `acme/searchProgress` notifications. - * - * Spawned via stdio by `examples/client/src/customMethodExample.ts`; do not run standalone. - */ -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import { z } from 'zod/v4'; - -const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) }); -const SearchResult = z.object({ items: z.array(z.string()) }); - -const mcp = new McpServer({ name: 'acme-search', version: '0.0.0' }); - -mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { - await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); - const items = Array.from({ length: params.limit }, (_, i) => `${params.query}-${i}`); - await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } }); - return { items }; -}); - -await mcp.connect(new StdioServerTransport()); diff --git a/examples/server/src/customProtocolVersion.ts b/examples/server/src/customProtocolVersion.ts deleted file mode 100644 index c580432e4b..0000000000 --- a/examples/server/src/customProtocolVersion.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Example: Custom Protocol Version Support - * - * This demonstrates how to support protocol versions not yet in the SDK. - * First version in the list is used as fallback when client requests - * an unsupported version. - * - * Run with: pnpm tsx src/customProtocolVersion.ts - */ - -import { randomUUID } from 'node:crypto'; -import { createServer } from 'node:http'; - -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; - -// Add support for a newer protocol version (first in list is fallback) -const CUSTOM_VERSIONS = ['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS]; - -const server = new McpServer( - { name: 'custom-protocol-server', version: '1.0.0' }, - { - supportedProtocolVersions: CUSTOM_VERSIONS, - capabilities: { tools: {} } - } -); - -// Register a tool that shows the protocol configuration -server.registerTool( - 'get-protocol-info', - { - title: 'Protocol Info', - description: 'Returns protocol version configuration' - }, - async (): Promise => ({ - content: [ - { - type: 'text', - text: JSON.stringify({ supportedVersions: CUSTOM_VERSIONS }, null, 2) - } - ] - }) -); - -// Create transport - server passes versions automatically during connect() -const transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID() -}); - -await server.connect(transport); - -// Simple HTTP server -const PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; - -createServer(async (req, res) => { - if (req.url === '/mcp') { - await transport.handleRequest(req, res); - } else { - res.writeHead(404).end('Not Found'); - } -}).listen(PORT, () => { - console.log(`MCP server with custom protocol versions on port ${PORT}`); - console.log(`Supported versions: ${CUSTOM_VERSIONS.join(', ')}`); -}); diff --git a/examples/server/src/dualEraStdio.ts b/examples/server/src/dualEraStdio.ts deleted file mode 100644 index 4153c38aeb..0000000000 --- a/examples/server/src/dualEraStdio.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Dual-era stdio serving with `serveStdio`: one server process, both protocol - * eras, one factory. - * - * The entry owns the era decision per connection: the client's opening - * exchange selects the era, one instance from the factory is pinned for the - * connection lifetime, and that instance serves only that era. - * - * - a plain 2025 client connects with the `initialize` handshake and is served - * by a 2025-era instance exactly as today; - * - a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) probes with - * `server/discover`, negotiates the 2026-07-28 revision, and is served by a - * 2026-era instance — every request carrying the per-request `_meta` - * envelope. - * - * The same factory backs both: tools are defined once and served identically - * to either kind of client. - * - * Run with `tsx examples/server/src/dualEraStdio.ts` (or point any stdio MCP - * client at it). `examples/client/src/dualEraStdioClient.ts` drives both legs - * against this file. - */ -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; -import { serveStdio } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; - -// One factory for both eras: tools are defined once and served identically to -// 2025-era and 2026-era clients. The entry constructs one instance per -// connection, for the era that connection's client opened with. -const buildServer = () => { - const server = new McpServer( - { - name: 'dual-era-stdio-server', - version: '1.0.0' - }, - { - capabilities: { tools: {} }, - instructions: 'A small dual-era stdio demo server.' - } - ); - - server.registerTool( - 'greet', - { - description: 'Greets the caller', - inputSchema: z.object({ name: z.string().describe('Name to greet') }) - }, - async ({ name }): Promise => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); - - return server; -}; - -// The entry owns the stdio transport and the era decision; 2025-era clients -// are served by default (`legacy: 'serve'`). -const handle = serveStdio(buildServer); -console.error('dual-era stdio server ready (serving 2025-era initialize and 2026-07-28 envelope traffic)'); - -const exit = async () => { - await handle.close(); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(0); -}; - -process.on('SIGINT', exit); -process.on('SIGTERM', exit); diff --git a/examples/server/src/dualEraStreamableHttp.ts b/examples/server/src/dualEraStreamableHttp.ts deleted file mode 100644 index 0ade70f793..0000000000 --- a/examples/server/src/dualEraStreamableHttp.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Dual-era HTTP serving with `createMcpHandler`: one factory, one endpoint, - * both protocol eras. - * - * The same factory backs both legacy postures; the `MCP_LEGACY_MODE` - * environment variable selects how 2025-era (non-envelope) traffic is handled: - * - * - unset / `MCP_LEGACY_MODE=stateless` → (the entry's default) 2025-era - * traffic is served per-request via the - * stateless idiom from the same factory. - * - `MCP_LEGACY_MODE=reject` → modern-only strict: 2026-07-28 requests are - * served, 2025-era requests get the documented - * rejection naming the supported revisions. - * - * To keep an existing sessionful 2025 deployment serving legacy traffic next - * to a strict endpoint, route in user land with the exported `isLegacyRequest` - * predicate in front of a `legacy: 'reject'` handler (see the createMcpHandler - * section of docs/migration.md for the pattern) — there is no handler-valued - * `legacy` option. - * - * Run with `tsx examples/server/src/dualEraStreamableHttp.ts`, then point any - * plain 2025 client at http://localhost:3000/mcp (served through the legacy - * fallback unless `reject` is selected). A `versionNegotiation: { mode: 'auto' }` - * client negotiates 2026-07-28 against the same endpoint and attaches the - * per-request `_meta` envelope itself once a modern era is negotiated, so - * ordinary typed calls (for example `callTool`) work against the modern leg - * without any per-call plumbing. - */ -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import type { CallToolResult, CreateMcpHandlerOptions, McpRequestContext } from '@modelcontextprotocol/server'; -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -// One factory for both legs (and both postures): tools are defined once and -// served identically to 2025-era and 2026-era clients. -const getServer = (ctx: McpRequestContext) => { - const server = new McpServer( - { - name: 'dual-era-server', - version: '1.0.0' - }, - { capabilities: { tools: {} }, instructions: 'A small dual-era demo server.' } - ); - - server.registerTool( - 'greet', - { - description: 'Greets the caller and reports which protocol era served the request', - inputSchema: z.object({ name: z.string().describe('Name to greet') }) - }, - async ({ name }): Promise => ({ - content: [{ type: 'text', text: `Hello, ${name}! (served on the ${ctx.era} protocol era)` }] - }) - ); - - return server; -}; - -const legacyMode = process.env.MCP_LEGACY_MODE ?? 'stateless'; -const options: CreateMcpHandlerOptions = { - onerror: error => console.error('MCP handler error:', error.message) -}; -if (legacyMode === 'reject') { - // Modern-only strict: turn the default stateless legacy fallback off. - options.legacy = 'reject'; -} - -const handler = createMcpHandler(getServer, options); - -// Origin/Host validation is middleware, not entry, concern: the Express app -// factory arms both for localhost binds by default. -const app = createMcpExpressApp(); - -app.all('/mcp', (req: Request, res: Response) => { - void handler.node(req, res, req.body); -}); - -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`Dual-era MCP server listening on http://localhost:${PORT}/mcp (legacy mode: ${legacyMode})`); -}); - -process.on('SIGINT', async () => { - await handler.close(); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(0); -}); diff --git a/examples/server/src/elicitationFormExample.ts b/examples/server/src/elicitationFormExample.ts deleted file mode 100644 index e059e8452d..0000000000 --- a/examples/server/src/elicitationFormExample.ts +++ /dev/null @@ -1,488 +0,0 @@ -// Run with: pnpm tsx src/elicitationFormExample.ts -// -// This example demonstrates how to use form elicitation to collect structured user input -// with JSON Schema validation via a local HTTP server with SSE streaming. -// Form elicitation allows servers to request *non-sensitive* user input through the client -// with schema-based validation. -// Note: See also elicitationUrlExample.ts for an example of using URL elicitation -// to collect *sensitive* user input via a browser. - -import { randomUUID } from 'node:crypto'; - -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; - -// Create a fresh MCP server per client connection to avoid shared state between clients. -// The validator supports format validation (email, date, etc.) if ajv-formats is installed. -const getServer = () => { - const mcpServer = new McpServer( - { - name: 'form-elicitation-example-server', - version: '1.0.0' - }, - { - capabilities: {} - } - ); - - /** - * Example 1: Simple user registration tool - * Collects username, email, and password from the user - */ - mcpServer.registerTool( - 'register_user', - { - description: 'Register a new user account by collecting their information' - }, - async () => { - try { - // Request user information through form elicitation - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Please provide your registration information:', - requestedSchema: { - type: 'object', - properties: { - username: { - type: 'string', - title: 'Username', - description: 'Your desired username (3-20 characters)', - minLength: 3, - maxLength: 20 - }, - email: { - type: 'string', - title: 'Email', - description: 'Your email address', - format: 'email' - }, - password: { - type: 'string', - title: 'Password', - description: 'Your password (min 8 characters)', - minLength: 8 - }, - newsletter: { - type: 'boolean', - title: 'Newsletter', - description: 'Subscribe to newsletter?', - default: false - } - }, - required: ['username', 'email', 'password'] - } - }); - - // Handle the different possible actions - if (result.action === 'accept' && result.content) { - const { username, email, newsletter } = result.content as { - username: string; - email: string; - password: string; - newsletter?: boolean; - }; - - return { - content: [ - { - type: 'text', - text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}` - } - ] - }; - } else if (result.action === 'decline') { - return { - content: [ - { - type: 'text', - text: 'Registration cancelled by user.' - } - ] - }; - } else { - return { - content: [ - { - type: 'text', - text: 'Registration was cancelled.' - } - ] - }; - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Registration failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); - - /** - * Example 2: Multi-step workflow with multiple form elicitation requests - * Demonstrates how to collect information in multiple steps - */ - mcpServer.registerTool( - 'create_event', - { - description: 'Create a calendar event by collecting event details' - }, - async () => { - try { - // Step 1: Collect basic event information - const basicInfo = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Step 1: Enter basic event information', - requestedSchema: { - type: 'object', - properties: { - title: { - type: 'string', - title: 'Event Title', - description: 'Name of the event', - minLength: 1 - }, - description: { - type: 'string', - title: 'Description', - description: 'Event description (optional)' - } - }, - required: ['title'] - } - }); - - if (basicInfo.action !== 'accept' || !basicInfo.content) { - return { - content: [{ type: 'text', text: 'Event creation cancelled.' }] - }; - } - - // Step 2: Collect date and time - const dateTime = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Step 2: Enter date and time', - requestedSchema: { - type: 'object', - properties: { - date: { - type: 'string', - title: 'Date', - description: 'Event date', - format: 'date' - }, - startTime: { - type: 'string', - title: 'Start Time', - description: 'Event start time (HH:MM)' - }, - duration: { - type: 'integer', - title: 'Duration', - description: 'Duration in minutes', - minimum: 15, - maximum: 480 - } - }, - required: ['date', 'startTime', 'duration'] - } - }); - - if (dateTime.action !== 'accept' || !dateTime.content) { - return { - content: [{ type: 'text', text: 'Event creation cancelled.' }] - }; - } - - // Combine all collected information - const event = { - ...basicInfo.content, - ...dateTime.content - }; - - return { - content: [ - { - type: 'text', - text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); - - /** - * Example 3: Collecting address information - * Demonstrates validation with patterns and optional fields - */ - mcpServer.registerTool( - 'update_shipping_address', - { - description: 'Update shipping address with validation' - }, - async () => { - try { - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Please provide your shipping address:', - requestedSchema: { - type: 'object', - properties: { - name: { - type: 'string', - title: 'Full Name', - description: 'Recipient name', - minLength: 1 - }, - street: { - type: 'string', - title: 'Street Address', - minLength: 1 - }, - city: { - type: 'string', - title: 'City', - minLength: 1 - }, - state: { - type: 'string', - title: 'State/Province', - minLength: 2, - maxLength: 2 - }, - zipCode: { - type: 'string', - title: 'ZIP/Postal Code', - description: '5-digit ZIP code' - }, - phone: { - type: 'string', - title: 'Phone Number (optional)', - description: 'Contact phone number' - } - }, - required: ['name', 'street', 'city', 'state', 'zipCode'] - } - }); - - if (result.action === 'accept' && result.content) { - return { - content: [ - { - type: 'text', - text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}` - } - ] - }; - } else if (result.action === 'decline') { - return { - content: [{ type: 'text', text: 'Address update cancelled by user.' }] - }; - } else { - return { - content: [{ type: 'text', text: 'Address update was cancelled.' }] - }; - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Address update failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); - - return mcpServer; -}; - -async function main() { - const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; - - const app = createMcpExpressApp(); - - // Map to store transports by session ID - const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - - // MCP POST endpoint - const mcpPostHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId) { - console.log(`Received MCP request for session: ${sessionId}`); - } - - try { - let transport: NodeStreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { - // Reuse existing transport for this session - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - create new transport - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - } - }); - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}, removing from transports map`); - delete transports[sid]; - } - }; - - // Connect a fresh MCP server to the transport BEFORE handling the request - const mcpServer = getServer(); - await mcpServer.connect(transport); - - await transport.handleRequest(req, res, req.body); - return; - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } - }; - - app.post('/mcp', mcpPostHandler); - - // Handle GET requests for SSE streams - const mcpGetHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Establishing SSE stream for session ${sessionId}`); - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - }; - - app.get('/mcp', mcpGetHandler); - - // Handle DELETE requests for session termination - const mcpDeleteHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling session termination:', error); - if (!res.headersSent) { - res.status(500).send('Error processing session termination'); - } - } - }; - - app.delete('/mcp', mcpDeleteHandler); - - // Start listening - app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`Form elicitation example server is running on http://localhost:${PORT}/mcp`); - console.log('Available tools:'); - console.log(' - register_user: Collect user registration information'); - console.log(' - create_event: Multi-step event creation'); - console.log(' - update_shipping_address: Collect and validate address'); - console.log('\nConnect your MCP client to this server using the HTTP transport.'); - }); - - // Handle server shutdown - process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); - }); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/src/elicitationUrlExample.ts b/examples/server/src/elicitationUrlExample.ts deleted file mode 100644 index 93b59152f8..0000000000 --- a/examples/server/src/elicitationUrlExample.ts +++ /dev/null @@ -1,738 +0,0 @@ -// Run with: pnpm tsx src/elicitationUrlExample.ts -// -// This example demonstrates how to use URL elicitation to securely collect -// *sensitive* user input in a remote (HTTP) server. -// URL elicitation allows servers to prompt the end-user to open a URL in their browser -// to collect sensitive information. -// Note: See also elicitationFormExample.ts for an example of using form (not URL) elicitation -// to collect *non-sensitive* user input with a structured schema. - -import { randomUUID } from 'node:crypto'; - -import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared'; -import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult, ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; -import cors from 'cors'; -import type { Request, Response } from 'express'; -import express from 'express'; -import * as z from 'zod/v4'; - -import { InMemoryEventStore } from './inMemoryEventStore.js'; - -// Create an MCP server with implementation details -const getServer = () => { - const mcpServer = new McpServer( - { - name: 'url-elicitation-http-server', - version: '1.0.0' - }, - { - capabilities: { logging: {} } - } - ); - - mcpServer.registerTool( - 'payment-confirm', - { - description: 'A tool that confirms a payment directly with a user', - inputSchema: z.object({ - cartId: z.string().describe('The ID of the cart to confirm') - }) - }, - async ({ cartId }, ctx): Promise => { - /* - In a real world scenario, there would be some logic here to check if the user has the provided cartId. - For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to confirm payment) - */ - const sessionId = ctx.sessionId; - if (!sessionId) { - throw new Error('Expected a Session ID'); - } - - // Create and track the elicitation - const elicitationId = generateTrackedElicitation(sessionId, elicitationId => - mcpServer.server.createElicitationCompletionNotifier(elicitationId) - ); - throw new UrlElicitationRequiredError([ - { - mode: 'url', - message: 'This tool requires a payment confirmation. Open the link to confirm payment!', - url: `http://localhost:${MCP_PORT}/confirm-payment?session=${sessionId}&elicitation=${elicitationId}&cartId=${encodeURIComponent(cartId)}`, - elicitationId - } - ]); - } - ); - - mcpServer.registerTool( - 'third-party-auth', - { - description: 'A demo tool that requires third-party OAuth credentials', - inputSchema: z.object({ - param1: z.string().describe('First parameter') - }) - }, - async (_, ctx): Promise => { - /* - In a real world scenario, there would be some logic here to check if we already have a valid access token for the user. - Auth info (with a subject or `sub` claim) can be typically be found in `ctx.http?.authInfo`. - If we do, we can just return the result of the tool call. - If we don't, we can throw an ElicitationRequiredError to request the user to authenticate. - For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to authenticate). - */ - const sessionId = ctx.sessionId; - if (!sessionId) { - throw new Error('Expected a Session ID'); - } - - // Create and track the elicitation - const elicitationId = generateTrackedElicitation(sessionId, elicitationId => - mcpServer.server.createElicitationCompletionNotifier(elicitationId) - ); - - // Simulate OAuth callback and token exchange after 5 seconds - // In a real app, this would be called from your OAuth callback handler - setTimeout(() => { - console.log(`Simulating OAuth token received for elicitation ${elicitationId}`); - completeURLElicitation(elicitationId); - }, 5000); - - throw new UrlElicitationRequiredError([ - { - mode: 'url', - message: 'This tool requires access to your example.com account. Open the link to authenticate!', - url: 'https://www.example.com/oauth/authorize', - elicitationId - } - ]); - } - ); - - return mcpServer; -}; - -/** - * Elicitation Completion Tracking Utilities - **/ - -interface ElicitationMetadata { - status: 'pending' | 'complete'; - completedPromise: Promise; - completeResolver: () => void; - createdAt: Date; - sessionId: string; - completionNotifier?: () => Promise; -} - -const elicitationsMap = new Map(); - -// Clean up old elicitations after 1 hour to prevent memory leaks -const ELICITATION_TTL_MS = 60 * 60 * 1000; // 1 hour -const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes - -function cleanupOldElicitations() { - const now = new Date(); - for (const [id, metadata] of elicitationsMap.entries()) { - if (now.getTime() - metadata.createdAt.getTime() > ELICITATION_TTL_MS) { - elicitationsMap.delete(id); - console.log(`Cleaned up expired elicitation: ${id}`); - } - } -} - -setInterval(cleanupOldElicitations, CLEANUP_INTERVAL_MS); - -/** - * Elicitation IDs must be unique strings within the MCP session - * UUIDs are used in this example for simplicity - */ -function generateElicitationId(): string { - return randomUUID(); -} - -/** - * Helper function to create and track a new elicitation. - */ -function generateTrackedElicitation(sessionId: string, createCompletionNotifier?: ElicitationCompletionNotifierFactory): string { - const elicitationId = generateElicitationId(); - - // Create a Promise and its resolver for tracking completion - let completeResolver: () => void; - const completedPromise = new Promise(resolve => { - completeResolver = resolve; - }); - - const completionNotifier = createCompletionNotifier ? createCompletionNotifier(elicitationId) : undefined; - - // Store the elicitation in our map - elicitationsMap.set(elicitationId, { - status: 'pending', - completedPromise, - completeResolver: completeResolver!, - createdAt: new Date(), - sessionId, - completionNotifier - }); - - return elicitationId; -} - -/** - * Helper function to complete an elicitation. - */ -function completeURLElicitation(elicitationId: string) { - const elicitation = elicitationsMap.get(elicitationId); - if (!elicitation) { - console.warn(`Attempted to complete unknown elicitation: ${elicitationId}`); - return; - } - - if (elicitation.status === 'complete') { - console.warn(`Elicitation already complete: ${elicitationId}`); - return; - } - - // Update metadata - elicitation.status = 'complete'; - - // Send completion notification to the client - if (elicitation.completionNotifier) { - console.log(`Sending notifications/elicitation/complete notification for elicitation ${elicitationId}`); - - elicitation.completionNotifier().catch(error => { - console.error(`Failed to send completion notification for elicitation ${elicitationId}:`, error); - }); - } - - // Resolve the promise to unblock any waiting code - elicitation.completeResolver(); -} - -const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; -const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; - -const app = createMcpExpressApp(); - -// Allow CORS all domains, expose the Mcp-Session-Id header -app.use( - cors({ - origin: '*', // Allow all origins - exposedHeaders: ['Mcp-Session-Id'], - credentials: true // Allow cookies to be sent cross-origin - }) -); - -// Set up OAuth (required for this example) -let authMiddleware = null; -// Create auth middleware for MCP endpoints -const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); -const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - -setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true }); - -// Add protected resource metadata route to the MCP server -// This allows clients to discover the auth server -// Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp -app.use(createProtectedResourceMetadataRouter('/mcp')); - -authMiddleware = requireBearerAuth({ - verifier: demoTokenVerifier, - requiredScopes: [], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) -}); - -/** - * API Key Form Handling - * - * Many servers today require an API key to operate, but there's no scalable way to do this dynamically for remote servers within MCP protocol. - * URL-mode elicitation enables the server to host a simple form and get the secret data securely from the user without involving the LLM or client. - **/ - -async function sendApiKeyElicitation( - sessionId: string, - sender: ElicitationSender, - createCompletionNotifier: ElicitationCompletionNotifierFactory -) { - if (!sessionId) { - console.error('No session ID provided'); - throw new Error('Expected a Session ID to track elicitation'); - } - - console.log('🔑 URL elicitation demo: Requesting API key from client...'); - const elicitationId = generateTrackedElicitation(sessionId, createCompletionNotifier); - try { - const result = await sender({ - mode: 'url', - message: 'Please provide your API key to authenticate with this server', - // Host the form on the same server. In a real app, you might coordinate passing these state variables differently. - url: `http://localhost:${MCP_PORT}/api-key-form?session=${sessionId}&elicitation=${elicitationId}`, - elicitationId - }); - - switch (result.action) { - case 'accept': { - console.log('🔑 URL elicitation demo: Client accepted the API key elicitation (now pending form submission)'); - // Wait for the API key to be submitted via the form - // The form submission will complete the elicitation - break; - } - default: { - console.log('🔑 URL elicitation demo: Client declined to provide an API key'); - // In a real app, this might close the connection, but for the demo, we'll continue - break; - } - } - } catch (error) { - console.error('Error during API key elicitation:', error); - } -} - -// API Key Form endpoint - serves a simple HTML form -app.get('/api-key-form', (req: Request, res: Response) => { - const mcpSessionId = req.query.session as string | undefined; - const elicitationId = req.query.elicitation as string | undefined; - if (!mcpSessionId || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie - // In production, this is often handled by some user auth middleware to ensure the user has a valid session - // This session is different from the MCP session. - // This userSession is the cookie that the MCP Server's Authorization Server sets for the user when they log in. - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - // Serve a simple HTML form - res.send(` - - - - Submit Your API Key - - - -

API Key Required

-
✓ Logged in as: ${userSession.name}
-
- - - - -
-
This is a demo showing how a server can securely elicit sensitive data from a user using a URL.
- - - `); -}); - -// Handle API key form submission -app.post('/api-key-form', express.urlencoded(), (req: Request, res: Response) => { - const { session: sessionId, apiKey, elicitation: elicitationId } = req.body; - if (!sessionId || !apiKey || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie here too - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - // A real app might store this API key to be used later for the user. - console.log(`🔑 Received API key \u001B[32m${apiKey}\u001B[0m for session ${sessionId}`); - - // If we have an elicitationId, complete the elicitation - completeURLElicitation(elicitationId); - - // Send a success response - res.send(` - - - - Success - - - -
-

Success ✓

-

API key received.

-
-

You can close this window and return to your MCP client.

- - - `); -}); - -// Helper to get the user session from the demo_session cookie -function getUserSessionCookie(cookieHeader?: string): { userId: string; name: string; timestamp: number } | null { - if (!cookieHeader) return null; - - const cookies = cookieHeader.split(';'); - for (const cookie of cookies) { - const [name, value] = cookie.trim().split('='); - if (name === 'demo_session' && value) { - try { - return JSON.parse(decodeURIComponent(value)); - } catch (error) { - console.error('Failed to parse demo_session cookie:', error); - return null; - } - } - } - return null; -} - -/** - * Payment Confirmation Form Handling - * - * This demonstrates how a server can use URL-mode elicitation to get user confirmation - * for sensitive operations like payment processing. - **/ - -// Payment Confirmation Form endpoint - serves a simple HTML form -app.get('/confirm-payment', (req: Request, res: Response) => { - const mcpSessionId = req.query.session as string | undefined; - const elicitationId = req.query.elicitation as string | undefined; - const cartId = req.query.cartId as string | undefined; - if (!mcpSessionId || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie - // In production, this is often handled by some user auth middleware to ensure the user has a valid session - // This session is different from the MCP session. - // This userSession is the cookie that the MCP Server's Authorization Server sets for the user when they log in. - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - // Serve a simple HTML form - res.send(` - - - - Confirm Payment - - - -

Confirm Payment

-
✓ Logged in as: ${userSession.name}
- ${cartId ? `
Cart ID: ${cartId}
` : ''} -
- ⚠️ Please review your order before confirming. -
-
- - - ${cartId ? `` : ''} - - -
-
This is a demo showing how a server can securely get user confirmation for sensitive operations using URL-mode elicitation.
- - - `); -}); - -// Handle Payment Confirmation form submission -app.post('/confirm-payment', express.urlencoded(), (req: Request, res: Response) => { - const { session: sessionId, elicitation: elicitationId, cartId, action } = req.body; - if (!sessionId || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie here too - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - if (action === 'confirm') { - // A real app would process the payment here - console.log(`💳 Payment confirmed for cart ${cartId || 'unknown'} by user ${userSession.name} (session ${sessionId})`); - - // Complete the elicitation - completeURLElicitation(elicitationId); - - // Send a success response - res.send(` - - - - Payment Confirmed - - - -
-

Payment Confirmed ✓

-

Your payment has been successfully processed.

- ${cartId ? `

Cart ID: ${cartId}

` : ''} -
-

You can close this window and return to your MCP client.

- - - `); - } else if (action === 'cancel') { - console.log(`💳 Payment cancelled for cart ${cartId || 'unknown'} by user ${userSession.name} (session ${sessionId})`); - - // The client will still receive a notifications/elicitation/complete notification, - // which indicates that the out-of-band interaction is complete (but not necessarily successful) - completeURLElicitation(elicitationId); - - res.send(` - - - - Payment Cancelled - - - -
-

Payment Cancelled

-

Your payment has been cancelled.

-
-

You can close this window and return to your MCP client.

- - - `); - } else { - res.status(400).send('

Error

Invalid action

'); - } -}); - -// Map to store transports by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - -// Interface for a function that can send an elicitation request -type ElicitationSender = (params: ElicitRequestURLParams) => Promise; -type ElicitationCompletionNotifierFactory = (elicitationId: string) => () => Promise; - -// Track sessions that need an elicitation request to be sent -interface SessionElicitationInfo { - elicitationSender: ElicitationSender; - createCompletionNotifier: ElicitationCompletionNotifierFactory; -} -const sessionsNeedingElicitation: { [sessionId: string]: SessionElicitationInfo } = {}; - -// MCP POST endpoint -const mcpPostHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - console.debug(`Received MCP POST for session: ${sessionId || 'unknown'}`); - - try { - let transport: NodeStreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - const server = getServer(); - // New initialization request - const eventStore = new InMemoryEventStore(); - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - sessionsNeedingElicitation[sessionId] = { - elicitationSender: params => server.server.elicitInput(params), - createCompletionNotifier: elicitationId => server.server.createElicitationCompletionNotifier(elicitationId) - }; - } - }); - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}, removing from transports map`); - delete transports[sid]; - delete sessionsNeedingElicitation[sid]; - } - }; - - // Connect the transport to the MCP server BEFORE handling the request - // so responses can flow back through the same transport - await server.connect(transport); - - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - // The existing transport is already connected to the server - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}; - -// Set up routes with auth middleware -app.post('/mcp', authMiddleware, mcpPostHandler); - -// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) -const mcpGetHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - // Check for Last-Event-ID header for resumability - const lastEventId = req.headers['last-event-id'] as string | undefined; - if (lastEventId) { - console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); - } else { - console.log(`Establishing new SSE stream for session ${sessionId}`); - } - - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - - if (sessionsNeedingElicitation[sessionId]) { - const { elicitationSender, createCompletionNotifier } = sessionsNeedingElicitation[sessionId]; - - // Send an elicitation request to the client in the background - sendApiKeyElicitation(sessionId, elicitationSender, createCompletionNotifier) - .then(() => { - // Only delete on successful send for this demo - delete sessionsNeedingElicitation[sessionId]; - console.log(`🔑 URL elicitation demo: Finished sending API key elicitation request for session ${sessionId}`); - }) - .catch(error => { - console.error('Error sending API key elicitation:', error); - // Keep in map to potentially retry on next reconnect - }); - } -}; - -// Set up GET route with conditional auth middleware -app.get('/mcp', authMiddleware, mcpGetHandler); - -// Handle DELETE requests for session termination (according to MCP spec) -const mcpDeleteHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling session termination:', error); - if (!res.headersSent) { - res.status(500).send('Error processing session termination'); - } - } -}; - -// Set up DELETE route with auth middleware -app.delete('/mcp', authMiddleware, mcpDeleteHandler); - -app.listen(MCP_PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); - console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - delete sessionsNeedingElicitation[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/server/src/honoWebStandardStreamableHttp.ts b/examples/server/src/honoWebStandardStreamableHttp.ts deleted file mode 100644 index b15f9885fa..0000000000 --- a/examples/server/src/honoWebStandardStreamableHttp.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Example MCP server using Hono with WebStandardStreamableHTTPServerTransport - * - * This example demonstrates using the Web Standard transport directly with Hono, - * which works on any runtime: Node.js, Cloudflare Workers, Deno, Bun, etc. - * - * Run with: pnpm tsx src/honoWebStandardStreamableHttp.ts - */ - -import { serve } from '@hono/node-server'; -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import * as z from 'zod/v4'; - -// Create the MCP server -const server = new McpServer({ - name: 'hono-webstandard-mcp-server', - version: '1.0.0' -}); - -// Register a simple greeting tool -server.registerTool( - 'greet', - { - title: 'Greeting Tool', - description: 'A simple greeting tool', - inputSchema: z.object({ name: z.string().describe('Name to greet') }) - }, - async ({ name }): Promise => { - return { - content: [{ type: 'text', text: `Hello, ${name}! (from Hono + WebStandard transport)` }] - }; - } -); - -// Create a stateless transport (no options = no session management) -const transport = new WebStandardStreamableHTTPServerTransport(); - -// Create the Hono app -const app = new Hono(); - -// Enable CORS for all origins -app.use( - '*', - cors({ - origin: '*', - allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'mcp-session-id', 'Last-Event-ID', 'mcp-protocol-version'], - exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'] - }) -); - -// Health check endpoint -app.get('/health', c => c.json({ status: 'ok' })); - -// MCP endpoint -app.all('/mcp', c => transport.handleRequest(c.req.raw)); - -// Start the server -const PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; - -await server.connect(transport); - -console.log(`Starting Hono MCP server on port ${PORT}`); -console.log(`Health check: http://localhost:${PORT}/health`); -console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); - -serve({ - fetch: app.fetch, - port: PORT -}); diff --git a/examples/server/src/jsonResponseStreamableHttp.ts b/examples/server/src/jsonResponseStreamableHttp.ts deleted file mode 100644 index 01759d6fc6..0000000000 --- a/examples/server/src/jsonResponseStreamableHttp.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -// Create an MCP server with implementation details -const getServer = () => { - const server = new McpServer( - { - name: 'json-response-streamable-http-server', - version: '1.0.0' - }, - { - capabilities: { - logging: {} - } - } - ); - - // Register a simple tool that returns a greeting - server.registerTool( - 'greet', - { - description: 'A simple greeting tool', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }) - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!` - } - ] - }; - } - ); - - // Register a tool that sends multiple greetings with notifications - server.registerTool( - 'multi-greet', - { - description: 'A tool that sends different greetings with delays between them', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }) - }, - async ({ name }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); - - await sleep(1000); // Wait 1 second before first greeting - - await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); - - await sleep(1000); // Wait another second before second greeting - - await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); - - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!` - } - ] - }; - } - ); - return server; -}; - -const app = createMcpExpressApp(); - -// Map to store transports by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - -app.post('/mcp', async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); - try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: NodeStreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - use JSON response mode - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, // Enable JSON response mode - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - } - }); - - // Connect the transport to the MCP server BEFORE handling the request - const server = getServer(); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); - -// Handle GET requests for SSE streams according to spec -app.get('/mcp', async (req: Request, res: Response) => { - // Since this is a very simple example, we don't support GET requests for this server - // The spec requires returning 405 Method Not Allowed in this case - res.status(405).set('Allow', 'POST').send('Method Not Allowed'); -}); - -// Start the server -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - process.exit(0); -}); diff --git a/examples/server/src/mcpServerOutputSchema.ts b/examples/server/src/mcpServerOutputSchema.ts deleted file mode 100644 index 955855c419..0000000000 --- a/examples/server/src/mcpServerOutputSchema.ts +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -/** - * Example MCP server using the high-level McpServer API with outputSchema - * This demonstrates how to easily create tools with structured output - */ - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; - -const server = new McpServer({ - name: 'mcp-output-schema-high-level-example', - version: '1.0.0' -}); - -// Define a tool with structured output - Weather data -server.registerTool( - 'get_weather', - { - description: 'Get weather information for a city', - inputSchema: z.object({ - city: z.string().describe('City name'), - country: z.string().describe('Country code (e.g., US, UK)') - }), - outputSchema: z.object({ - temperature: z.object({ - celsius: z.number(), - fahrenheit: z.number() - }), - conditions: z.enum(['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']), - humidity: z.number().min(0).max(100), - wind: z.object({ - speed_kmh: z.number(), - direction: z.string() - }) - }) - }, - async ({ city, country }) => { - // Parameters are available but not used in this example - void city; - void country; - // Simulate weather API call - const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)]; - - const structuredContent = { - temperature: { - celsius: temp_c, - fahrenheit: Math.round(((temp_c * 9) / 5 + 32) * 10) / 10 - }, - conditions, - humidity: Math.round(Math.random() * 100), - wind: { - speed_kmh: Math.round(Math.random() * 50), - direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)] - } - }; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(structuredContent, null, 2) - } - ], - structuredContent - }; - } -); - -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('High-level Output Schema Example Server running on stdio'); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/src/resourceServerOnly.ts b/examples/server/src/resourceServerOnly.ts deleted file mode 100644 index 1a1708177e..0000000000 --- a/examples/server/src/resourceServerOnly.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Minimal Resource-Server-only auth using the SDK's RS helpers - * (`mcpAuthMetadataRouter`, `requireBearerAuth`, `OAuthTokenVerifier`). - * - * No better-auth. The Authorization Server is external; this example points - * its metadata at a placeholder issuer. For a full AS+RS setup with a real - * demo Authorization Server, see {@link ./simpleStreamableHttp.ts}. - * - * Run: pnpm tsx src/resourceServerOnly.ts - * Probe: curl http://localhost:3000/.well-known/oauth-protected-resource/mcp - * curl -H 'Authorization: Bearer demo-token' -X POST http://localhost:3000/mcp ... - */ - -import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; -import { - createMcpExpressApp, - getOAuthProtectedResourceMetadataUrl, - mcpAuthMetadataRouter, - requireBearerAuth -} from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { AuthInfo, CallToolResult, OAuthMetadata } from '@modelcontextprotocol/server'; -import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -const PORT = 3000; -const mcpServerUrl = new URL(`http://localhost:${PORT}/mcp`); - -// In a real deployment this is your external Authorization Server's metadata -// (RFC 8414). The SDK router serves it verbatim at -// /.well-known/oauth-authorization-server so clients probing the RS origin -// can still discover the AS. -const oauthMetadata: OAuthMetadata = { - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - response_types_supported: ['code'] -}; - -// Replace with JWT verification, RFC 7662 introspection, etc. -const staticTokenVerifier: OAuthTokenVerifier = { - async verifyAccessToken(token): Promise { - if (token !== 'demo-token') { - throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); - } - return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 }; - } -}; - -const server = new McpServer({ name: 'rs-only', version: '1.0.0' }, { capabilities: {} }); -server.registerTool( - 'whoami', - { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, - async (_args, ctx): Promise => ({ - content: [{ type: 'text', text: `client=${ctx.http?.authInfo?.clientId ?? 'anon'}` }] - }) -); - -const app = createMcpExpressApp(); - -app.use( - mcpAuthMetadataRouter({ - oauthMetadata, - resourceServerUrl: mcpServerUrl, - resourceName: 'RS-only example' - }) -); - -const auth = requireBearerAuth({ - verifier: staticTokenVerifier, - requiredScopes: ['mcp'], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) -}); - -app.post('/mcp', auth, async (req: Request, res: Response) => { - const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - res.on('close', () => void transport.close()); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); -}); - -app.listen(PORT, () => { - console.log(`RS-only MCP server on http://localhost:${PORT}/mcp`); - console.log(` PRM: ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`); - console.log(` AS metadata mirror: http://localhost:${PORT}/.well-known/oauth-authorization-server`); -}); diff --git a/examples/server/src/simpleStatelessStreamableHttp.ts b/examples/server/src/simpleStatelessStreamableHttp.ts deleted file mode 100644 index 2b4f0363d8..0000000000 --- a/examples/server/src/simpleStatelessStreamableHttp.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -const getServer = () => { - // Create an MCP server with implementation details - const server = new McpServer( - { - name: 'stateless-streamable-http-server', - version: '1.0.0' - }, - { capabilities: { logging: {} } } - ); - - // Register a simple prompt - server.registerPrompt( - 'greeting-template', - { - description: 'A simple greeting prompt template', - argsSchema: z.object({ - name: z.string().describe('Name to include in greeting') - }) - }, - async ({ name }): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please greet ${name} in a friendly manner.` - } - } - ] - }; - } - ); - - // Register a tool specifically for testing resumability - server.registerTool( - 'start-notification-stream', - { - description: 'Starts sending periodic notifications for testing resumability', - inputSchema: z.object({ - interval: z.number().describe('Interval in milliseconds between notifications').default(100), - count: z.number().describe('Number of notifications to send (0 for 100)').default(10) - }) - }, - async ({ interval, count }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await ctx.mcpReq.log('info', `Periodic notification #${counter} at ${new Date().toISOString()}`); - } catch (error) { - console.error('Error sending notification:', error); - } - // Wait for the specified interval - await sleep(interval); - } - - return { - content: [ - { - type: 'text', - text: `Started sending periodic notifications every ${interval}ms` - } - ] - }; - } - ); - - // Create a simple resource at a fixed URI - server.registerResource( - 'greeting-resource', - 'https://example.com/greetings/default', - { mimeType: 'text/plain' }, - async (): Promise => { - return { - contents: [ - { - uri: 'https://example.com/greetings/default', - text: 'Hello, world!' - } - ] - }; - } - ); - return server; -}; - -const app = createMcpExpressApp(); - -app.post('/mcp', async (req: Request, res: Response) => { - const server = getServer(); - try { - const transport: NodeStreamableHTTPServerTransport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: undefined - }); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - res.on('close', () => { - console.log('Request closed'); - transport.close(); - server.close(); - }); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); - -app.get('/mcp', async (req: Request, res: Response) => { - console.log('Received GET MCP request'); - res.writeHead(405).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32_000, - message: 'Method not allowed.' - }, - id: null - }) - ); -}); - -app.delete('/mcp', async (req: Request, res: Response) => { - console.log('Received DELETE MCP request'); - res.writeHead(405).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32_000, - message: 'Method not allowed.' - }, - id: null - }) - ); -}); - -// Start the server -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(0); -}); diff --git a/examples/server/src/simpleStreamableHttp.ts b/examples/server/src/simpleStreamableHttp.ts deleted file mode 100644 index 1f0998cca9..0000000000 --- a/examples/server/src/simpleStreamableHttp.ts +++ /dev/null @@ -1,658 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared'; -import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { - CallToolResult, - GetPromptResult, - PrimitiveSchemaDefinition, - ReadResourceResult, - ResourceLink -} from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import cors from 'cors'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -import { InMemoryEventStore } from './inMemoryEventStore.js'; - -// Check for OAuth flag -const useOAuth = process.argv.includes('--oauth'); -const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled'); - -// Create an MCP server with implementation details -const getServer = () => { - const server = new McpServer( - { - name: 'simple-streamable-http-server', - version: '1.0.0', - icons: [{ src: './mcp.svg', sizes: ['512x512'], mimeType: 'image/svg+xml' }], - websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk' - }, - { - capabilities: { - logging: {} - } - } - ); - - // Register a simple tool that returns a greeting - server.registerTool( - 'greet', - { - title: 'Greeting Tool', // Display name for UI - description: 'A simple greeting tool', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }) - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!` - } - ] - }; - } - ); - - // Register a tool that sends multiple greetings with notifications (with annotations) - server.registerTool( - 'multi-greet', - { - description: 'A tool that sends different greetings with delays between them', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }), - annotations: { - title: 'Multiple Greeting Tool', - readOnlyHint: true, - openWorldHint: false - } - }, - async ({ name }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); - - await sleep(1000); // Wait 1 second before first greeting - - await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); - - await sleep(1000); // Wait another second before second greeting - - await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); - - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!` - } - ] - }; - } - ); - // Register a tool that demonstrates form elicitation (user input collection with a schema) - // This creates a closure that captures the server instance - server.registerTool( - 'collect-user-info', - { - description: 'A tool that collects user information through form elicitation', - inputSchema: z.object({ - infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') - }) - }, - async ({ infoType }, ctx): Promise => { - let message: string; - let requestedSchema: { - type: 'object'; - properties: Record; - required?: string[]; - }; - - switch (infoType) { - case 'contact': { - message = 'Please provide your contact information'; - requestedSchema = { - type: 'object', - properties: { - name: { - type: 'string', - title: 'Full Name', - description: 'Your full name' - }, - email: { - type: 'string', - title: 'Email Address', - description: 'Your email address', - format: 'email' - }, - phone: { - type: 'string', - title: 'Phone Number', - description: 'Your phone number (optional)' - } - }, - required: ['name', 'email'] - }; - break; - } - case 'preferences': { - message = 'Please set your preferences'; - requestedSchema = { - type: 'object', - properties: { - theme: { - type: 'string', - title: 'Theme', - description: 'Choose your preferred theme', - enum: ['light', 'dark', 'auto'], - enumNames: ['Light', 'Dark', 'Auto'] - }, - notifications: { - type: 'boolean', - title: 'Enable Notifications', - description: 'Would you like to receive notifications?', - default: true - }, - frequency: { - type: 'string', - title: 'Notification Frequency', - description: 'How often would you like notifications?', - enum: ['daily', 'weekly', 'monthly'], - enumNames: ['Daily', 'Weekly', 'Monthly'] - } - }, - required: ['theme'] - }; - break; - } - case 'feedback': { - message = 'Please provide your feedback'; - requestedSchema = { - type: 'object', - properties: { - rating: { - type: 'integer', - title: 'Rating', - description: 'Rate your experience (1-5)', - minimum: 1, - maximum: 5 - }, - comments: { - type: 'string', - title: 'Comments', - description: 'Additional comments (optional)', - maxLength: 500 - }, - recommend: { - type: 'boolean', - title: 'Would you recommend this?', - description: 'Would you recommend this to others?' - } - }, - required: ['rating', 'recommend'] - }; - break; - } - default: { - throw new Error(`Unknown info type: ${infoType}`); - } - } - - try { - // Use sendRequest through the ctx parameter to elicit input - const result = await ctx.mcpReq.send({ - method: 'elicitation/create', - params: { - mode: 'form', - message, - requestedSchema - } - }); - - if (result.action === 'accept') { - return { - content: [ - { - type: 'text', - text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}` - } - ] - }; - } else if (result.action === 'decline') { - return { - content: [ - { - type: 'text', - text: `No information was collected. User declined ${infoType} information request.` - } - ] - }; - } else { - return { - content: [ - { - type: 'text', - text: `Information collection was cancelled by the user.` - } - ] - }; - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Error collecting ${infoType} information: ${error}` - } - ] - }; - } - } - ); - - // Register a simple prompt with title - server.registerPrompt( - 'greeting-template', - { - title: 'Greeting Template', // Display name for UI - description: 'A simple greeting prompt template', - argsSchema: z.object({ - name: z.string().describe('Name to include in greeting') - }) - }, - async ({ name }): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please greet ${name} in a friendly manner.` - } - } - ] - }; - } - ); - - // Register a tool specifically for testing resumability - server.registerTool( - 'start-notification-stream', - { - description: 'Starts sending periodic notifications for testing resumability', - inputSchema: z.object({ - interval: z.number().describe('Interval in milliseconds between notifications').default(100), - count: z.number().describe('Number of notifications to send (0 for 100)').default(50) - }) - }, - async ({ interval, count }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await ctx.mcpReq.log('info', `Periodic notification #${counter} at ${new Date().toISOString()}`); - } catch (error) { - console.error('Error sending notification:', error); - } - // Wait for the specified interval - await sleep(interval); - } - - return { - content: [ - { - type: 'text', - text: `Started sending periodic notifications every ${interval}ms` - } - ] - }; - } - ); - - // Create a simple resource at a fixed URI - server.registerResource( - 'greeting-resource', - 'https://example.com/greetings/default', - { - title: 'Default Greeting', // Display name for UI - description: 'A simple greeting resource', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'https://example.com/greetings/default', - text: 'Hello, world!' - } - ] - }; - } - ); - - // Create additional resources for ResourceLink demonstration - server.registerResource( - 'example-file-1', - 'file:///example/file1.txt', - { - title: 'Example File 1', - description: 'First example file for ResourceLink demonstration', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'file:///example/file1.txt', - text: 'This is the content of file 1' - } - ] - }; - } - ); - - server.registerResource( - 'example-file-2', - 'file:///example/file2.txt', - { - title: 'Example File 2', - description: 'Second example file for ResourceLink demonstration', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'file:///example/file2.txt', - text: 'This is the content of file 2' - } - ] - }; - } - ); - - // Register a tool that returns ResourceLinks - server.registerTool( - 'list-files', - { - title: 'List Files with ResourceLinks', - description: 'Returns a list of files as ResourceLinks without embedding their content', - inputSchema: z.object({ - includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links') - }) - }, - async ({ includeDescriptions = true }): Promise => { - const resourceLinks: ResourceLink[] = [ - { - type: 'resource_link', - uri: 'https://example.com/greetings/default', - name: 'Default Greeting', - mimeType: 'text/plain', - ...(includeDescriptions && { description: 'A simple greeting resource' }) - }, - { - type: 'resource_link', - uri: 'file:///example/file1.txt', - name: 'Example File 1', - mimeType: 'text/plain', - ...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' }) - }, - { - type: 'resource_link', - uri: 'file:///example/file2.txt', - name: 'Example File 2', - mimeType: 'text/plain', - ...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' }) - } - ]; - - return { - content: [ - { - type: 'text', - text: 'Here are the available files as resource links:' - }, - ...resourceLinks, - { - type: 'text', - text: '\nYou can read any of these resources using their URI.' - } - ] - }; - } - ); - - return server; -}; - -const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; -const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; - -const app = createMcpExpressApp(); - -// Enable CORS for browser-based clients (demo only) -// This allows cross-origin requests and exposes WWW-Authenticate header for OAuth -// WARNING: This configuration is for demo purposes only. In production, you should restrict this to specific origins and configure CORS yourself. -app.use( - cors({ - exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'], - origin: '*' // WARNING: This allows all origins to access the MCP server. In production, you should restrict this to specific origins. - }) -); - -// Set up OAuth if enabled -let authMiddleware = null; -if (useOAuth) { - // Create auth middleware for MCP endpoints - const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); - const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - - setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, dangerousLoggingEnabled }); - - // Add protected resource metadata route to the MCP server - // This allows clients to discover the auth server - // Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp - app.use(createProtectedResourceMetadataRouter('/mcp')); - - authMiddleware = requireBearerAuth({ - verifier: demoTokenVerifier, - requiredScopes: [], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) - }); -} - -// Map to store transports by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - -// MCP POST endpoint with optional auth -const mcpPostHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId) { - console.log(`Received MCP request for session: ${sessionId}`); - } else { - console.log('Request body:', req.body); - } - - if (useOAuth && req.auth) { - console.log('Authenticated user:', req.auth); - } - try { - let transport: NodeStreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - const eventStore = new InMemoryEventStore(); - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - } - }); - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}, removing from transports map`); - delete transports[sid]; - } - }; - - // Connect the transport to the MCP server BEFORE handling the request - // so responses can flow back through the same transport - const server = getServer(); - await server.connect(transport); - - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - // The existing transport is already connected to the server - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}; - -// Set up routes with conditional auth middleware -if (useOAuth && authMiddleware) { - app.post('/mcp', authMiddleware, mcpPostHandler); -} else { - app.post('/mcp', mcpPostHandler); -} - -// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) -const mcpGetHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - if (useOAuth && req.auth) { - console.log('Authenticated SSE connection from user:', req.auth); - } - - // Check for Last-Event-ID header for resumability - const lastEventId = req.headers['last-event-id'] as string | undefined; - if (lastEventId) { - console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); - } else { - console.log(`Establishing new SSE stream for session ${sessionId}`); - } - - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}; - -// Set up GET route with conditional auth middleware -if (useOAuth && authMiddleware) { - app.get('/mcp', authMiddleware, mcpGetHandler); -} else { - app.get('/mcp', mcpGetHandler); -} - -// Handle DELETE requests for session termination (according to MCP spec) -const mcpDeleteHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling session termination:', error); - if (!res.headersSent) { - res.status(500).send('Error processing session termination'); - } - } -}; - -// Set up DELETE route with conditional auth middleware -if (useOAuth && authMiddleware) { - app.delete('/mcp', authMiddleware, mcpDeleteHandler); -} else { - app.delete('/mcp', mcpDeleteHandler); -} - -app.listen(MCP_PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); - if (useOAuth) { - console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); - } -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/server/src/standaloneSseWithGetStreamableHttp.ts b/examples/server/src/standaloneSseWithGetStreamableHttp.ts deleted file mode 100644 index 7e133f6d2e..0000000000 --- a/examples/server/src/standaloneSseWithGetStreamableHttp.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { ReadResourceResult } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; - -// Helper to register a dynamic resource on a given server instance -const addResource = (server: McpServer, name: string, content: string) => { - const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; - server.registerResource( - name, - uri, - { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, - async (): Promise => { - return { - contents: [{ uri, text: content }] - }; - } - ); -}; - -// Create a fresh MCP server per client connection to avoid shared state between clients -const getServer = () => { - const server = new McpServer({ - name: 'resource-list-changed-notification-server', - version: '1.0.0' - }); - - addResource(server, 'example-resource', 'Initial content for example-resource'); - - return server; -}; - -// Store transports and their associated servers by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; -const servers: { [sessionId: string]: McpServer } = {}; - -// Periodically add a new resource to all active server instances for testing -const resourceChangeInterval = setInterval(() => { - const name = randomUUID(); - for (const sessionId in servers) { - addResource(servers[sessionId]!, name, `Content for ${name}`); - } -}, 5000); // Change resources every 5 seconds for testing - -const app = createMcpExpressApp(); - -app.post('/mcp', async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); - try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: NodeStreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - create a fresh server for this client - const server = getServer(); - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: sessionId => { - // Store the transport and server by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - servers[sessionId] = server; - } - }); - - // Clean up both maps when the transport closes - transport.onclose = () => { - const sid = transport.sessionId; - if (sid) { - delete transports[sid]; - delete servers[sid]; - } - }; - - // Connect the fresh MCP server to the transport - await server.connect(transport); - - // Handle the request - the onsessioninitialized callback will store the transport - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); - -// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) -app.get('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Establishing SSE stream for session ${sessionId}`); - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}); - -// Start the server -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - clearInterval(resourceChangeInterval); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - delete servers[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/server/src/toolWithSampleServer.ts b/examples/server/src/toolWithSampleServer.ts deleted file mode 100644 index f6b053cf24..0000000000 --- a/examples/server/src/toolWithSampleServer.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Run with: pnpm tsx src/toolWithSampleServer.ts - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; - -const mcpServer = new McpServer({ - name: 'tools-with-sample-server', - version: '1.0.0' -}); - -// Tool that uses LLM sampling to summarize any text -mcpServer.registerTool( - 'summarize', - { - description: 'Summarize any text using an LLM', - inputSchema: z.object({ - text: z.string().describe('Text to summarize') - }) - }, - async ({ text }) => { - // Call the LLM through MCP sampling - const response = await mcpServer.server.createMessage({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please summarize the following text concisely:\n\n${text}` - } - } - ], - maxTokens: 500 - }); - - // Since we're not passing tools param to createMessage, response.content is single content - return { - content: [ - { - type: 'text', - text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary' - } - ] - }; - } -); - -async function main() { - const transport = new StdioServerTransport(); - await mcpServer.connect(transport); - console.log('MCP server is running...'); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/src/valibotExample.ts b/examples/server/src/valibotExample.ts deleted file mode 100644 index 8d92bf1993..0000000000 --- a/examples/server/src/valibotExample.ts +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -/** - * Minimal MCP server using Valibot for schema validation. - * Use toStandardJsonSchema() from @valibot/to-json-schema to create - * StandardJSONSchemaV1-compliant schemas. - */ - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import { toStandardJsonSchema } from '@valibot/to-json-schema'; -import * as v from 'valibot'; - -const server = new McpServer({ - name: 'valibot-example', - version: '1.0.0' -}); - -// Register a tool with Valibot schema -server.registerTool( - 'greet', - { - description: 'Generate a greeting', - inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) -); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/examples/server/tsdown.config.ts b/examples/server/tsdown.config.ts deleted file mode 100644 index efc4299d35..0000000000 --- a/examples/server/tsdown.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - // 1. Entry Points - // Directly matches package.json include/exclude globs - entry: ['src/**/*.ts'], - - // 2. Output Configuration - format: ['esm'], - outDir: 'dist', - clean: true, // Recommended: Cleans 'dist' before building - sourcemap: true, - - // 3. Platform & Target - target: 'esnext', - platform: 'node', - shims: true, // Polyfills common Node.js shims (__dirname, etc.) - - // 4. Type Definitions - // Bundles d.ts files into a single output - dts: false, - // 5. Vendoring Strategy - Bundle the code for this specific package into the output, - // but treat all other dependencies as external (require/import). - noExternal: ['@modelcontextprotocol/examples-shared'] -}); diff --git a/examples/server/vitest.config.js b/examples/server/vitest.config.js deleted file mode 100644 index 496fca3200..0000000000 --- a/examples/server/vitest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; diff --git a/examples/shared/package.json b/examples/shared/package.json index 0bab8be920..4b99310e9d 100644 --- a/examples/shared/package.json +++ b/examples/shared/package.json @@ -1,5 +1,5 @@ { - "name": "@modelcontextprotocol/examples-shared", + "name": "@mcp-examples/shared", "private": true, "version": "2.0.0-alpha.0", "description": "Model Context Protocol implementation for TypeScript", @@ -8,6 +8,9 @@ "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", "type": "module", + "exports": { + ".": "./src/index.ts" + }, "repository": { "type": "git", "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" @@ -21,15 +24,11 @@ ], "scripts": { "typecheck": "tsgo -p tsconfig.json --noEmit", - "prepack": "pnpm run build:esm && pnpm run build:cjs", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "pnpm run typecheck && pnpm run lint", "test": "vitest run", - "test:watch": "vitest", - "start": "pnpm run server", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" + "test:watch": "vitest" }, "dependencies": { "@modelcontextprotocol/core": "workspace:^", diff --git a/examples/shared/src/authServer.ts b/examples/shared/src/authServer.ts index 995fedc7d9..5f895087e0 100644 --- a/examples/shared/src/authServer.ts +++ b/examples/shared/src/authServer.ts @@ -15,7 +15,7 @@ import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; import { toNodeHandler } from 'better-auth/node'; import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins'; import cors from 'cors'; -import type { Request, Response as ExpressResponse, Router } from 'express'; +import type { NextFunction, Request, Response as ExpressResponse, Router } from 'express'; import express from 'express'; import type { DemoAuth } from './auth.js'; @@ -34,6 +34,22 @@ export interface SetupAuthServerOptions { * Only use for debugging purposes. */ dangerousLoggingEnabled?: boolean; + /** + * DEMO ONLY. When `true`, the `/api/auth/mcp/authorize` endpoint skips the + * consent screen entirely and immediately 302s back to the client's + * `redirect_uri` with an authorization `code` — exactly what would happen + * after a real user clicked **Approve**. Mechanically this strips the OIDC + * `prompt` parameter from the request before it reaches better-auth, so the + * MCP plugin's authorize handler takes its no-consent fast path. Combined + * with the `/sign-in` page that auto-signs-in the demo user, the entire + * authorization-code flow becomes a deterministic chain of 302s a headless + * client can follow with `fetch(..., { redirect: 'manual' })`. + * + * The `examples/oauth/` server enables this when + * `OAUTH_DEMO_AUTO_CONSENT=1` so the CI client (`client.ts`) can drive the + * full browser flow without a browser. NEVER enable in production. + */ + autoConsent?: boolean; } // Store auth instance globally so it can be used for token verification @@ -88,7 +104,7 @@ async function ensureDemoUserExists(auth: DemoAuth): Promise { * @param options - Server configuration */ export function setupAuthServer(options: SetupAuthServerOptions): void { - const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false } = options; + const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false, autoConsent = false } = options; // Create better-auth instance with MCP plugin const auth = createDemoAuth({ @@ -116,6 +132,31 @@ export function setupAuthServer(options: SetupAuthServerOptions): void { // toNodeHandler bypasses Express methods const betterAuthHandler = toNodeHandler(auth); + // DEMO ONLY: simulate the user clicking "Approve" on the consent screen. + // The SDK auth driver appends `prompt=consent` whenever it requests the + // `offline_access` scope (per OIDC §11). With a real user, better-auth + // would render a consent UI and wait for an explicit Approve; here we drop + // `prompt` from the query before it reaches better-auth so its authorize + // handler takes the no-consent fast path and 302s straight back to + // `redirect_uri?code=...`. See {@link SetupAuthServerOptions.autoConsent}. + if (autoConsent) { + authApp.use((req: Request, _res: ExpressResponse, next: NextFunction) => { + const qmark = req.url.indexOf('?'); + if (req.path === '/api/auth/mcp/authorize' && qmark !== -1) { + const search = new URLSearchParams(req.url.slice(qmark + 1)); + if (search.has('prompt')) { + search.delete('prompt'); + const qs = search.toString(); + // toNodeHandler reconstructs the Fetch Request from req.url + // (req.baseUrl is empty at the app level), so rewriting it + // here is what better-auth's handler will see. + req.url = `/api/auth/mcp/authorize${qs ? `?${qs}` : ''}`; + } + } + next(); + }); + } + // Mount better-auth handler BEFORE body parsers // toNodeHandler reads the raw request body, so Express must not consume it first if (dangerousLoggingEnabled) { diff --git a/examples/shared/src/clientCredentialsAuthServer.ts b/examples/shared/src/clientCredentialsAuthServer.ts new file mode 100644 index 0000000000..b4d87b0462 --- /dev/null +++ b/examples/shared/src/clientCredentialsAuthServer.ts @@ -0,0 +1,135 @@ +/** + * Minimal OAuth 2.0 Authorization Server supporting the **`client_credentials`** + * grant only — for the machine-to-machine MCP example. + * + * DEMO ONLY — NOT FOR PRODUCTION + * + * The full {@link setupAuthServer} (better-auth/OIDC) only supports the + * `authorization_code` grant; this is the headless counterpart so the + * `oauth-client-credentials/` example can be fully self-verifying without a + * browser. + * + * Exposes RFC 8414 metadata at `/.well-known/oauth-authorization-server` and a + * `/token` endpoint that accepts `client_secret_basic` or `client_secret_post` + * authentication. Issued access tokens are random opaque strings tracked in an + * in-memory map and validated by {@link clientCredentialsTokenVerifier}. + */ + +import { randomBytes } from 'node:crypto'; + +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import express from 'express'; + +export interface RegisteredClient { + clientId: string; + clientSecret: string; + /** Scopes the AS is willing to grant this client (defaults to whatever it asks for). */ + allowedScopes?: string[]; +} + +export interface ClientCredentialsAuthServerOptions { + /** Public base URL of this AS (issuer). */ + authServerUrl: URL; + /** Pre-registered confidential clients. */ + clients: RegisteredClient[]; +} + +export interface ClientCredentialsAuthServer { + app: express.Application; + metadata: OAuthMetadata; + /** Pass to `requireBearerAuth({ verifier })` on the Resource Server. */ + verifier: OAuthTokenVerifier; +} + +/** Tokens issued by the most-recently-created `client_credentials` AS. */ +const issuedTokens = new Map(); + +/** + * Builds (but does not `listen()`) a minimal `client_credentials`-only + * Authorization Server. The caller mounts `app` on the port matching + * `authServerUrl`. + */ +export function createClientCredentialsAuthServer(options: ClientCredentialsAuthServerOptions): ClientCredentialsAuthServer { + const { authServerUrl, clients } = options; + const issuer = authServerUrl.href.replace(/\/$/, ''); + const clientById = new Map(clients.map(c => [c.clientId, c])); + + const metadata: OAuthMetadata = { + issuer, + token_endpoint: `${issuer}/token`, + // Required by the RFC 8414 schema even though this AS doesn't implement the endpoint. + authorization_endpoint: `${issuer}/authorize`, + response_types_supported: [], + grant_types_supported: ['client_credentials'], + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + scopes_supported: ['mcp:tools', 'mcp:read'] + }; + + const app = express(); + app.use(cors()); + app.use(express.urlencoded({ extended: false })); + + app.get('/.well-known/oauth-authorization-server', (_req, res) => { + res.json(metadata); + }); + + app.post('/token', (req, res) => { + const body = req.body as Record; + if (body.grant_type !== 'client_credentials') { + res.status(400).json({ error: 'unsupported_grant_type' }); + return; + } + // RFC 6749 §2.3.1 — try Basic, then body. + let id: string | undefined; + let secret: string | undefined; + const authz = req.header('authorization'); + if (authz?.startsWith('Basic ')) { + const decoded = Buffer.from(authz.slice(6), 'base64').toString('utf8'); + const sep = decoded.indexOf(':'); + id = decodeURIComponent(decoded.slice(0, sep)); + secret = decodeURIComponent(decoded.slice(sep + 1)); + } else { + id = body.client_id; + secret = body.client_secret; + } + const client = id ? clientById.get(id) : undefined; + if (!client || client.clientSecret !== secret) { + res.status(401).set('WWW-Authenticate', 'Basic realm="oauth"').json({ error: 'invalid_client' }); + return; + } + const requested = (body.scope ?? '').split(' ').filter(Boolean); + const granted = client.allowedScopes ? requested.filter(s => client.allowedScopes!.includes(s)) : requested; + const accessToken = randomBytes(24).toString('base64url'); + const expiresIn = 3600; + issuedTokens.set(accessToken, { + token: accessToken, + clientId: client.clientId, + scopes: granted, + expiresAt: Math.floor(Date.now() / 1000) + expiresIn + }); + res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: expiresIn, scope: granted.join(' ') }); + }); + + return { app, metadata, verifier: clientCredentialsTokenVerifier }; +} + +/** + * `OAuthTokenVerifier` that validates Bearer tokens against the in-memory + * issued-tokens map of {@link createClientCredentialsAuthServer}. + */ +export const clientCredentialsTokenVerifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + const info = issuedTokens.get(token); + if (!info) throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + // Model expiry explicitly even in the demo so copy-paste users don't ship a fail-open verifier. + // `requireBearerAuth` also independently rejects when `AuthInfo.expiresAt` is in the past. + if (info.expiresAt !== undefined && Math.floor(Date.now() / 1000) >= info.expiresAt) { + issuedTokens.delete(token); + throw new OAuthError(OAuthErrorCode.InvalidToken, 'token expired'); + } + return info; + } +}; diff --git a/examples/server/src/inMemoryEventStore.ts b/examples/shared/src/inMemoryEventStore.ts similarity index 100% rename from examples/server/src/inMemoryEventStore.ts rename to examples/shared/src/inMemoryEventStore.ts diff --git a/examples/shared/src/index.ts b/examples/shared/src/index.ts index 47c4d67109..62b0f7ecd7 100644 --- a/examples/shared/src/index.ts +++ b/examples/shared/src/index.ts @@ -5,3 +5,10 @@ export { createDemoAuth } from './auth.js'; // Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express) export type { SetupAuthServerOptions } from './authServer.js'; export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js'; + +// In-memory EventStore for resumability examples (sse-polling, repl) +export { InMemoryEventStore } from './inMemoryEventStore.js'; + +// Minimal client_credentials-only AS (machine-to-machine; no browser) +export type { ClientCredentialsAuthServer, ClientCredentialsAuthServerOptions, RegisteredClient } from './clientCredentialsAuthServer.js'; +export { clientCredentialsTokenVerifier, createClientCredentialsAuthServer } from './clientCredentialsAuthServer.js'; diff --git a/examples/sse-polling/README.md b/examples/sse-polling/README.md new file mode 100644 index 0000000000..fef5261ba8 --- /dev/null +++ b/examples/sse-polling/README.md @@ -0,0 +1,12 @@ +# sse-polling + +SEP-1699 server-initiated SSE disconnection + client reconnection with `Last-Event-ID` replay. **Sessionful 2025** by definition (the feature lives on `NodeStreamableHTTPServerTransport` + an `EventStore`). `eventStore` resumability is a 2025-session concern with no 2026-07-28 +per-request equivalent. + +The `long-operation` tool emits two log notifications, calls `ctx.http?.closeSSE()` mid-stream, emits two more while the client is disconnected, then returns. The client transport reconnects after `retryInterval` (300 ms) with `Last-Event-ID`; the event store replays the buffered +events. The client asserts the result arrived AND the post-disconnect log was delivered. + +```bash +pnpm --filter @mcp-examples/sse-polling server -- --http --port 3001 # term 1 +pnpm --filter @mcp-examples/sse-polling client -- --http http://127.0.0.1:3001/mcp # term 2 +``` diff --git a/examples/sse-polling/client.ts b/examples/sse-polling/client.ts new file mode 100644 index 0000000000..934503505f --- /dev/null +++ b/examples/sse-polling/client.ts @@ -0,0 +1,51 @@ +/** + * SSE Polling Example Client (SEP-1699) + * + * Connects (2025-era), calls `long-operation`, and asserts the result arrives + * AFTER the server's mid-stream `closeSSE()` — i.e. the client transport + * automatically reconnects with `Last-Event-ID` and replays the events the + * `eventStore` buffered while disconnected. Also asserts every progress log + * (including the one emitted while disconnected) was delivered. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, runClient } from '../harness.js'; + +const URL = httpUrlFromArgs('http://127.0.0.1:3001/mcp'); + +runClient('sse-polling', async () => { + const transport = new StreamableHTTPClientTransport(new globalThis.URL(URL)); + // The mid-stream disconnect surfaces as a transport error before the + // automatic reconnect; that is the EXPECTED flow, not a failure. + transport.onerror = () => {}; + + // Explicitly the 2025 `initialize` handshake — `closeSSE`/`eventStore` live + // on the sessionful-2025 transport, so this story is legacy-only by design + // (it was previously reaching 2025 by negotiation fallback; pin it). + const client = new Client({ name: 'sse-polling-client', version: '1.0.0' }, { versionNegotiation: { mode: 'legacy' } }); + const logs: string[] = []; + client.setNotificationHandler('notifications/message', n => { + logs.push(String(n.params.data)); + }); + await client.connect(transport); + + let lastEventId: string | undefined; + const result = await client.request( + { method: 'tools/call', params: { name: 'long-operation', arguments: {} } }, + { onresumptiontoken: token => (lastEventId = token) } + ); + + const text = (result as { content?: Array<{ type: string; text?: string }> }).content?.[0]?.text ?? ''; + check.match(text, /completed successfully/); + check.ok(lastEventId, 'resumption tokens should have been observed'); + // The 75% line is emitted WHILE the client is disconnected; receiving it + // proves the event store replayed it on reconnect. (Replay ordering relative + // to the terminal result is not asserted — the result resolving is the + // signal the disconnect was survived.) + check.ok( + logs.some(l => l.includes('75%')), + `events emitted while disconnected should be replayed (got: ${logs.join(' | ')})` + ); + + await client.close(); +}); diff --git a/examples/sse-polling/package.json b/examples/sse-polling/package.json new file mode 100644 index 0000000000..b66b6cad33 --- /dev/null +++ b/examples/sse-polling/package.json @@ -0,0 +1,30 @@ +{ + "name": "@mcp-examples/sse-polling", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "legacy", + "path": "/mcp", + "timeoutMs": 20000, + "//": "SEP-1699 closeSSE/eventStore/retryInterval live on the sessionful-2025 transport; the client is era-blind so dual would duplicate." + } +} diff --git a/examples/server/src/ssePollingExample.ts b/examples/sse-polling/server.ts similarity index 58% rename from examples/server/src/ssePollingExample.ts rename to examples/sse-polling/server.ts index 2675a038ed..70af622c95 100644 --- a/examples/server/src/ssePollingExample.ts +++ b/examples/sse-polling/server.ts @@ -9,20 +9,18 @@ * - Uses `eventStore` to persist events for replay after reconnection * - Uses `ctx.http?.closeSSE()` callback to gracefully disconnect clients mid-operation * - * Run with: pnpm tsx src/ssePollingExample.ts - * Test with: curl or the MCP Inspector + * HTTP-only, sessionful 2025 by definition. */ import { randomUUID } from 'node:crypto'; +import { InMemoryEventStore } from '@mcp-examples/shared'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; -import { InMemoryEventStore } from './inMemoryEventStore.js'; - // Create a fresh MCP server per client connection to avoid shared state between clients const getServer = () => { const server = new McpServer( @@ -44,33 +42,33 @@ const getServer = () => { async (ctx): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - console.log(`[${ctx.sessionId}] Starting long-operation...`); + console.error(`[${ctx.sessionId}] Starting long-operation...`); // Send first progress notification await ctx.mcpReq.log('info', 'Progress: 25% - Starting work...'); - await sleep(1000); + await sleep(200); // Send second progress notification await ctx.mcpReq.log('info', 'Progress: 50% - Halfway there...'); - await sleep(1000); + await sleep(200); // Server decides to disconnect the client to free resources // Client will reconnect via GET with Last-Event-ID after the transport's retryInterval // Use ctx.http?.closeSSE callback - available when eventStore is configured if (ctx.http?.closeSSE) { - console.log(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`); + console.error(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`); ctx.http?.closeSSE(); } // Continue processing while client is disconnected // Events are stored in eventStore and will be replayed on reconnect - await sleep(500); + await sleep(200); await ctx.mcpReq.log('info', 'Progress: 75% - Almost done (sent while client disconnected)...'); - await sleep(500); + await sleep(200); await ctx.mcpReq.log('info', 'Progress: 100% - Complete!'); - console.log(`[${ctx.sessionId}] Operation complete`); + console.error(`[${ctx.sessionId}] Operation complete`); return { content: [ @@ -96,40 +94,41 @@ const eventStore = new InMemoryEventStore(); // Track transports by session ID for session reuse const transports = new Map(); -// Handle all MCP requests +// Handle all MCP requests (standard sessionful routing: known sid → reuse; +// no sid + initialize → new session; unknown sid → 404; otherwise → 400). app.all('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - // Reuse existing transport or create new one - let transport = sessionId ? transports.get(sessionId) : undefined; - - if (!transport) { - transport = new NodeStreamableHTTPServerTransport({ + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && transports.has(sid)) { + await transports.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, - retryInterval: 2000, // Default retry interval for priming events + retryInterval: 300, // Default retry interval for priming events onsessioninitialized: id => { - console.log(`[${id}] Session initialized`); - transports.set(id, transport!); + console.error(`[${id}] Session initialized`); + transports.set(id, transport); } }); - - // Connect a fresh MCP server to the transport - const server = getServer(); - await server.connect(transport); + transport.onclose = () => transport.sessionId && transports.delete(transport.sessionId); + await getServer().connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + // Unknown/expired session ID → 404 so the client knows to re-initialize. + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); } - - await transport.handleRequest(req, res, req.body); }); // Start the server -const PORT = 3001; +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const PORT = portIdx === -1 ? Number(process.env.PORT ?? 3001) : Number(argv[portIdx + 1]); app.listen(PORT, () => { - console.log(`SSE Polling Example Server running on http://localhost:${PORT}/mcp`); - console.log(''); - console.log('This server demonstrates SEP-1699 SSE polling:'); - console.log('- retryInterval: 2000ms (client waits 2s before reconnecting)'); - console.log('- eventStore: InMemoryEventStore (events are persisted for replay)'); - console.log(''); - console.log('Try calling the "long-operation" tool to see server-initiated disconnect in action.'); + console.error(`SSE Polling Example Server running on http://localhost:${PORT}/mcp`); + console.error('This server demonstrates SEP-1699 SSE polling:'); + console.error('- retryInterval: 300ms (client waits before reconnecting)'); + console.error('- eventStore: InMemoryEventStore (events are persisted for replay)'); + console.error('Try calling the "long-operation" tool to see server-initiated disconnect in action.'); }); diff --git a/examples/standalone-get/README.md b/examples/standalone-get/README.md new file mode 100644 index 0000000000..085ec69799 --- /dev/null +++ b/examples/standalone-get/README.md @@ -0,0 +1,8 @@ +# standalone-get + +Server-initiated `notifications/resources/list_changed` over the **standalone GET** SSE stream (sessionful 2025). The `add_resource` tool registers a new resource on the session's instance, which emits the notification over the GET stream the client opened via +`ClientOptions.listChanged`; the client calls the tool and asserts the notification arrived. + +The original timer-driven unsolicited push (server emits on its own schedule) was traded for this tool-triggered approach for CI determinism — the `list_changed`-over-standalone-GET behaviour is still demonstrated; "server pushes on its own schedule" is no longer shown. + +**HTTP-only**, sessionful 2025 by definition. diff --git a/examples/standalone-get/client.ts b/examples/standalone-get/client.ts new file mode 100644 index 0000000000..433aaeac0b --- /dev/null +++ b/examples/standalone-get/client.ts @@ -0,0 +1,43 @@ +/** + * Connects (2025-era), opens the standalone GET stream by registering a + * `listChanged` handler, calls `add_resource` to trigger a + * `notifications/resources/list_changed` over that stream, and asserts it + * arrived. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, runClient } from '../harness.js'; + +const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); + +runClient('standalone-get', async () => { + let received = 0; + const client = new Client( + { name: 'standalone-get-client', version: '1.0.0' }, + { + // Explicitly the 2025 `initialize` handshake — the standalone GET + // stream is a sessionful-2025 transport feature, so this story is + // legacy-only by design (was reaching 2025 by fallback; pin it). + versionNegotiation: { mode: 'legacy' }, + listChanged: { resources: { autoRefresh: false, debounceMs: 0, onChanged: () => void received++ } } + } + ); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + + const before = await client.listResources(); + check.ok(before.resources.length > 0); + + // Mutate on demand → server emits list_changed over the standalone GET stream. + await client.callTool({ name: 'add_resource', arguments: { content: 'hello' } }); + const deadline = Date.now() + 5000; + while (received < 1) { + if (Date.now() > deadline) throw new Error('no listChanged within 5s'); + await new Promise(r => setTimeout(r, 25)); + } + check.ok(received >= 1); + + const after = await client.listResources(); + check.ok(after.resources.length > before.resources.length); + + await client.close(); +}); diff --git a/examples/standalone-get/package.json b/examples/standalone-get/package.json new file mode 100644 index 0000000000..5ae0d1af2f --- /dev/null +++ b/examples/standalone-get/package.json @@ -0,0 +1,28 @@ +{ + "name": "@mcp-examples/standalone-get", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "express": "catalog:runtimeServerOnly", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "legacy", + "path": "/mcp", + "//": "The standalone GET stream is a sessionful-2025 transport feature; the client is era-blind so dual would duplicate." + } +} diff --git a/examples/standalone-get/server.ts b/examples/standalone-get/server.ts new file mode 100644 index 0000000000..061342b555 --- /dev/null +++ b/examples/standalone-get/server.ts @@ -0,0 +1,96 @@ +/** + * Standalone GET stream + `notifications/resources/list_changed` (sessionful + * 2025). + * + * One `NodeStreamableHTTPServerTransport` + `McpServer` per session, the way + * you would deploy a sessionful 2025 server. The `add_resource` tool registers + * a new resource on the session's instance — `McpServer.registerResource` emits + * `notifications/resources/list_changed`, which on a sessionful transport + * travels over the **standalone GET** SSE stream the client opened. The client + * decides when to mutate (no timer race in the harness). + * + * **HTTP-only**, sessionful 2025 by definition. + */ +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +const buildServer = () => { + const server = new McpServer( + { name: 'standalone-get-example', version: '1.0.0' }, + { capabilities: { resources: { listChanged: true } } } + ); + let nextId = 1; + const register = (name: string, content: string) => + server.registerResource( + name, + `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`, + { mimeType: 'text/plain' }, + async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/plain', text: content }] + }) + ); + register('initial', 'Initial content'); + + server.registerTool( + 'add_resource', + { + description: + 'Register a new resource on this session — emits notifications/resources/list_changed over the standalone GET stream.', + inputSchema: z.object({ content: z.string() }) + }, + async ({ content }) => { + const name = `note-${nextId++}`; + register(name, content); + return { content: [{ type: 'text', text: `registered ${name}` }] }; + } + ); + return server; +}; + +const sessions = new Map(); +const app = createMcpExpressApp(); + +app.post('/mcp', async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + await buildServer().connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); + } +}); + +// The standalone GET stream (the point of this story) and DELETE (explicit +// session termination per the MCP spec) route to the session's transport. +const sessionVerb = async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + const t = sid ? sessions.get(sid) : undefined; + if (!t) { + res.status(sid ? 404 : 400).send(sid ? 'Session not found' : 'Missing session ID'); + return; + } + await t.handleRequest(req, res); +}; +app.get('/mcp', sessionVerb); +app.delete('/mcp', sessionVerb); + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? Number(process.env.PORT ?? 3000) : Number(argv[portIdx + 1]); +app.listen(port, () => console.error(`standalone-get example server listening on http://127.0.0.1:${port}/mcp`)); diff --git a/examples/stateless-legacy/README.md b/examples/stateless-legacy/README.md new file mode 100644 index 0000000000..d3b0250ec1 --- /dev/null +++ b/examples/stateless-legacy/README.md @@ -0,0 +1,5 @@ +# stateless-legacy + +The minimal `createMcpHandler` deployment, on its default posture: 2026-07-28 traffic served per request, 2025-era traffic served stateless from the same factory. This is the one-liner replacement for the 1.x "new transport + new server per POST" stateless idiom. + +**HTTP-only** by definition; see `dual-era/` for the stdio analogue. diff --git a/examples/stateless-legacy/client.ts b/examples/stateless-legacy/client.ts new file mode 100644 index 0000000000..0057e27a22 --- /dev/null +++ b/examples/stateless-legacy/client.ts @@ -0,0 +1,23 @@ +/** + * Connects to the minimal `createMcpHandler` deployment as both a plain 2025 + * client (the `initialize` handshake, served stateless from the factory) and + * a 2026-capable client (`versionNegotiation: { mode: 'auto' }`, served per + * request). Asserts the same `greet` tool answers identically either way. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, runClient } from '../harness.js'; + +const URL = httpUrlFromArgs('http://127.0.0.1:3000/'); + +runClient('stateless-legacy', async () => { + for (const mode of [undefined, { mode: 'auto' as const }]) { + const client = new Client({ name: 'stateless-legacy-client', version: '1.0.0' }, mode ? { versionNegotiation: mode } : {}); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const tools = await client.listTools(); + check.ok(tools.tools.some(t => t.name === 'greet')); + const result = await client.callTool({ name: 'greet', arguments: { name: 'world' } }); + check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'Hello, world!'); + await client.close(); + } +}); diff --git a/examples/stateless-legacy/package.json b/examples/stateless-legacy/package.json new file mode 100644 index 0000000000..3066b195ec --- /dev/null +++ b/examples/stateless-legacy/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mcp-examples/stateless-legacy", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." + } +} diff --git a/examples/stateless-legacy/server.ts b/examples/stateless-legacy/server.ts new file mode 100644 index 0000000000..fce9094076 --- /dev/null +++ b/examples/stateless-legacy/server.ts @@ -0,0 +1,30 @@ +/** + * The minimal `createMcpHandler` deployment, on its default posture. + * + * One factory, one endpoint: 2026-07-28 traffic is served per request, and + * 2025-era (non-envelope) traffic is served stateless from the same factory + * (`legacy: 'stateless'`, the default). This replaces the hand-wired + * "new transport + new server per POST" stateless idiom of the 1.x SDK with + * a one-liner. + */ +import { createServer } from 'node:http'; + +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'stateless-legacy-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + server.registerTool( + 'greet', + { description: 'A simple greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) + ); + return server; +}); + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +createServer((req, res) => void handler.node(req, res)).listen(port, () => { + console.error(`stateless-legacy example server listening on http://127.0.0.1:${port}/`); +}); diff --git a/examples/stickynotes/README.md b/examples/stickynotes/README.md new file mode 100644 index 0000000000..886e5e113f --- /dev/null +++ b/examples/stickynotes/README.md @@ -0,0 +1,7 @@ +# stickynotes + +The "real app" capstone: a sticky-notes board where tools mutate state, each note is a resource, the resource list changes on add/remove, and a destructive `remove_all` blocks on a form-mode elicitation. The client adds, lists, reads, removes, and proves `remove_all` only clears +the board on an explicit confirm. + +Runs the full transport × era matrix. The `remove_all` confirmation is a push server→client elicitation (2025-era only — there is no server→client request channel on 2026-07-28; the equivalent is multi-round-trip `inputRequired`, see `../elicitation/`). The legacy legs exercise +the full cancel / unchecked / confirm flow over both stdio and the harness's sessionful http arm; the modern legs exercise add / list / read / remove and skip `remove_all`. diff --git a/examples/stickynotes/client.ts b/examples/stickynotes/client.ts new file mode 100644 index 0000000000..ab9cd76841 --- /dev/null +++ b/examples/stickynotes/client.ts @@ -0,0 +1,89 @@ +/** + * Drives the sticky-notes board end to end: add two notes, list/read their + * resources, remove one, then — on the 2025-era leg — attempt `remove_all` + * three ways (cancel, accept-unchecked, accept-confirmed) to prove the board is + * cleared only on an explicit confirmation. + */ +import { check, connectFromArgs, eraLeg, runClient } from '../harness.js'; + +interface AddResult { + id: string; + uri: string; +} +interface RemoveAllResult { + status: string; + removed: number; +} + +runClient('stickynotes', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {} } } }); + let elicitAnswer: 'cancel' | 'unchecked' | 'confirm' = 'cancel'; + client.setRequestHandler('elicitation/create', async () => { + if (elicitAnswer === 'cancel') return { action: 'cancel' }; + return { action: 'accept', content: { confirm: elicitAnswer === 'confirm' } }; + }); + + // ADD two notes. + const first = await client.callTool({ name: 'add_note', arguments: { text: 'Buy milk' } }); + const firstNote = first.structuredContent as unknown as AddResult; + check.match(firstNote.uri, /^note:\/\/\//); + const second = await client.callTool({ name: 'add_note', arguments: { text: 'Walk the dog' } }); + const secondNote = second.structuredContent as unknown as AddResult; + check.notEqual(firstNote.id, secondNote.id); + + // LIST/READ — both notes should be listable resources. + const list = await client.listResources(); + const noteUris = new Set(list.resources.filter(r => r.uri.startsWith('note:///')).map(r => r.uri)); + check.ok(noteUris.has(firstNote.uri) && noteUris.has(secondNote.uri)); + const read = await client.readResource({ uri: firstNote.uri }); + const readContent = read.contents[0]; + check.equal(readContent && 'text' in readContent ? readContent.text : '', 'Buy milk'); + + // REMOVE ONE. + const removed = await client.callTool({ name: 'remove_note', arguments: { id: firstNote.id } }); + check.equal((removed.structuredContent as { removed?: boolean } | undefined)?.removed, true); + const after = await client.listResources(); + check.ok(!after.resources.some(r => r.uri === firstNote.uri)); + + // The elicitation-confirmed `remove_all` path is 2025-era only: push-style + // server→client requests need the `initialize` handshake to advertise the + // elicitation capability and a long-lived bidirectional connection (stdio, + // or the harness's sessionful http/legacy arm). On a 2026-07-28 connection + // there is no server→client request channel — the equivalent is + // multi-round-trip `inputRequired` (see ../elicitation/). + if (eraLeg() === 'modern') { + const removedSecond = await client.callTool({ name: 'remove_note', arguments: { id: secondNote.id } }); + check.equal((removedSecond.structuredContent as { removed?: boolean } | undefined)?.removed, true); + const afterClear = await client.listResources(); + check.equal(afterClear.resources.filter(r => r.uri.startsWith('note:///')).length, 0); + await client.close(); + return; + } + + // CANCEL — board untouched. + elicitAnswer = 'cancel'; + const cancelled = await client.callTool({ name: 'remove_all' }); + check.equal((cancelled.structuredContent as unknown as RemoveAllResult).status, 'cancelled'); + const afterCancel = await client.listResources(); + check.ok(afterCancel.resources.some(r => r.uri === secondNote.uri)); + + // UNCHECKED — accept with confirm:false → declined, board untouched. + elicitAnswer = 'unchecked'; + const declined = await client.callTool({ name: 'remove_all' }); + check.equal((declined.structuredContent as unknown as RemoveAllResult).status, 'declined'); + + // CONFIRM — accept with confirm:true → cleared. + elicitAnswer = 'confirm'; + const cleared = await client.callTool({ name: 'remove_all' }); + check.equal((cleared.structuredContent as unknown as RemoveAllResult).status, 'cleared'); + check.equal((cleared.structuredContent as unknown as RemoveAllResult).removed, 1); + const afterClear = await client.listResources(); + check.equal(afterClear.resources.filter(r => r.uri.startsWith('note:///')).length, 0); + + // EMPTY — a follow-up remove_all reports 'empty' without eliciting. + const empty = await client.callTool({ name: 'remove_all' }); + check.equal((empty.structuredContent as unknown as RemoveAllResult).status, 'empty'); + + await client.close(); +}); diff --git a/examples/stickynotes/package.json b/examples/stickynotes/package.json new file mode 100644 index 0000000000..8e10cc8529 --- /dev/null +++ b/examples/stickynotes/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mcp-examples/stickynotes", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "dual", + "//": "Full transport × era matrix. The elicitation-confirmed remove_all path is 2025-era only (push-style server→client request); the modern legs exercise add/list/read/remove and skip remove_all." + } +} diff --git a/examples/stickynotes/server.ts b/examples/stickynotes/server.ts new file mode 100644 index 0000000000..c46ee0d924 --- /dev/null +++ b/examples/stickynotes/server.ts @@ -0,0 +1,115 @@ +/** + * "Real app" capstone — a small stateful sticky-notes board that ties + * together tools that mutate state, a resource per piece of state, listChanged + * on add/remove, and a server→client elicitation guarding a destructive action. + * + * The board is process-local (one map per server process). Over stdio one + * `McpServer` instance is pinned for the connection lifetime, so the tools + * register/unregister note resources at runtime; over the per-request HTTP + * path the factory registers a resource per live note on every request. + * + * Tools: + * - `add_note(text)` — store a note, register `note:///{id}`, returns + * `{id, uri}`. + * - `remove_note(id)` — delete one note + unregister its resource. + * - `remove_all()` — delete every note, but FIRST blocks on a form-mode + * elicitation; declining/cancelling/unchecked all leave the board. + * + * One binary, either transport. + */ +import type { RegisteredResource } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +const notes = new Map(); +let nextId = 1; +const uriFor = (id: string) => `note:///${id}`; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'stickynotes-example', version: '1.0.0' }, { capabilities: { resources: { listChanged: true } } }); + // Registrations on THIS instance (so the stdio leg can unregister at runtime). + const registered = new Map(); + const registerNote = (id: string, text: string) => { + const r = server.registerResource( + `note-${id}`, + uriFor(id), + { mimeType: 'text/plain', description: `Sticky note #${id}` }, + async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/plain', text: notes.get(id) ?? text }] + }) + ); + registered.set(id, r); + }; + // Register a resource per live note (per-request HTTP path picks up the + // current board on every factory call; stdio re-uses one instance). + for (const [id, text] of notes) registerNote(id, text); + + server.registerTool( + 'add_note', + { + description: 'Add a sticky note; registers a note:///{id} resource for it.', + inputSchema: z.object({ text: z.string() }), + outputSchema: z.object({ id: z.string(), uri: z.string() }) + }, + async ({ text }) => { + const id = String(nextId++); + notes.set(id, text); + registerNote(id, text); + const structuredContent = { id, uri: uriFor(id) }; + return { content: [{ type: 'text', text: `added note #${id}` }], structuredContent }; + } + ); + + server.registerTool( + 'remove_note', + { + description: 'Remove one sticky note by id and unregister its resource.', + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ removed: z.boolean(), id: z.string() }) + }, + async ({ id }) => { + const removed = notes.delete(id); + if (removed) registered.get(id)?.remove(); + return { content: [{ type: 'text', text: removed ? `removed #${id}` : 'not found' }], structuredContent: { removed, id } }; + } + ); + + server.registerTool( + 'remove_all', + { + description: 'Remove ALL sticky notes after confirming via a server→client elicitation.', + outputSchema: z.object({ status: z.string(), removed: z.number() }) + }, + async ctx => { + if (notes.size === 0) { + return { content: [{ type: 'text', text: 'nothing to clear' }], structuredContent: { status: 'empty', removed: 0 } }; + } + const count = notes.size; + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Remove all ${count} sticky note(s)? This cannot be undone.`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Yes, permanently delete every sticky note' } }, + required: ['confirm'] + } + }); + if (result.action === 'cancel') { + return { content: [{ type: 'text', text: 'cancelled' }], structuredContent: { status: 'cancelled', removed: 0 } }; + } + if (result.action !== 'accept' || !(result.content as { confirm?: boolean } | undefined)?.confirm) { + return { content: [{ type: 'text', text: 'declined' }], structuredContent: { status: 'declined', removed: 0 } }; + } + for (const id of notes.keys()) registered.get(id)?.remove(); + notes.clear(); + return { content: [{ type: 'text', text: `cleared ${count}` }], structuredContent: { status: 'cleared', removed: count } }; + } + ); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/streaming/README.md b/examples/streaming/README.md new file mode 100644 index 0000000000..a62b1f1496 --- /dev/null +++ b/examples/streaming/README.md @@ -0,0 +1,8 @@ +# streaming + +The three in-flight channels: progress (via `_meta.progressToken` → `notifications/progress` → the client's `onprogress` callback), logging (`ctx.mcpReq.notify({ method: 'notifications/message', … })` — request-tied so it rides the same response stream as progress; the +connection-level `ctx.mcpReq.log` shorthand sends an unrelated notification a per-request HTTP entry cannot deliver mid-call), and cancellation (the client's `AbortSignal` → `ctx.mcpReq.signal.aborted` server-side). + +```bash +pnpm tsx examples/streaming/client.ts +``` diff --git a/examples/streaming/client.ts b/examples/streaming/client.ts new file mode 100644 index 0000000000..450445cdd5 --- /dev/null +++ b/examples/streaming/client.ts @@ -0,0 +1,46 @@ +/** + * Drives the streaming example: a `countdown` call with `onprogress` + * (asserts progress notifications arrived), a logging-notification handler + * (asserts log messages arrived), and a cancelled call (asserts the cancel + * propagated). + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('streaming', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname); + + let logCount = 0; + client.setNotificationHandler('notifications/message', () => { + logCount++; + }); + + // --- progress + logging --- + let progressCount = 0; + const result = await client.callTool( + { name: 'countdown', arguments: { n: 5, delayMs: 20 } }, + { + onprogress: p => { + progressCount++; + check.equal(p.total, 5); + } + } + ); + check.equal((result.structuredContent as { completed?: number } | undefined)?.completed, 5); + check.equal((result.structuredContent as { cancelled?: boolean } | undefined)?.cancelled, false); + check.ok(progressCount >= 4, `expected >=4 progress notifications, got ${progressCount}`); + check.ok(logCount >= 4, `expected >=4 log notifications, got ${logCount}`); + + // --- cancellation propagation --- + const ac = new AbortController(); + setTimeout(() => ac.abort(), 60); + let cancelled = false; + try { + await client.callTool({ name: 'countdown', arguments: { n: 50, delayMs: 50 } }, { signal: ac.signal }); + } catch { + cancelled = true; + } + check.ok(cancelled, 'a client-side abort should reject the in-flight callTool'); + + await client.close(); +}); diff --git a/examples/streaming/package.json b/examples/streaming/package.json new file mode 100644 index 0000000000..df70e5b96b --- /dev/null +++ b/examples/streaming/package.json @@ -0,0 +1,16 @@ +{ + "name": "@mcp-examples/streaming", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/streaming/server.ts b/examples/streaming/server.ts new file mode 100644 index 0000000000..4c84bcf8d0 --- /dev/null +++ b/examples/streaming/server.ts @@ -0,0 +1,59 @@ +/** + * In-flight channels: progress, logging, cancellation. + * + * The `countdown` tool emits a `notifications/progress` per step (when the + * call carried a `_meta.progressToken`), a logging notification per step + * (when the server has the `logging` capability), and stops promptly when the + * client cancels (`ctx.mcpReq.signal.aborted`). One binary, either transport. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'streaming-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + + server.registerTool( + 'countdown', + { + description: 'Counts down from N, emitting progress + log per step; stops on cancellation', + inputSchema: z.object({ n: z.number().int().min(1).max(50), delayMs: z.number().int().min(0).default(50) }), + outputSchema: z.object({ completed: z.number(), total: z.number(), cancelled: z.boolean() }) + }, + async ({ n, delayMs }, ctx) => { + const progressToken = ctx.mcpReq._meta?.progressToken; + let completed = 0; + for (let i = 0; i < n; i++) { + if (ctx.mcpReq.signal.aborted) break; + await new Promise(r => setTimeout(r, delayMs)); + completed++; + if (progressToken !== undefined) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: completed, total: n, message: `step ${completed}/${n}` } + }); + } + // Send the log message as a request-tied notification so it + // rides the same response stream as the progress notification + // (the connection-level `ctx.mcpReq.log` shorthand sends an + // unrelated notification, which a per-request HTTP entry + // cannot deliver mid-call). + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { level: 'info', logger: 'countdown', data: `countdown step ${completed}/${n}` } + }); + } + const structuredContent = { completed, total: n, cancelled: ctx.mcpReq.signal.aborted }; + return { + content: [{ type: 'text', text: `completed ${completed}/${n}${structuredContent.cancelled ? ' (cancelled)' : ''}` }], + structuredContent + }; + } + ); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/subscriptions/README.md b/examples/subscriptions/README.md new file mode 100644 index 0000000000..c7e9d5ce3c --- /dev/null +++ b/examples/subscriptions/README.md @@ -0,0 +1,16 @@ +# subscriptions + +`subscriptions/listen` change-notification streams (protocol revision 2026-07-28). The server publishes `tools/list_changed`; the client receives it both via the auto-opened stream (`ClientOptions.listChanged`, the same option a 2025-era client sets) and a manual +`client.listen()` call. + +The publish surface differs by entry: over HTTP (`createMcpHandler`) the example calls `handler.notify.toolsChanged()` on the cross-request `ServerEventBus`; over stdio (`serveStdio`) it toggles a `RegisteredTool` on the pinned instance, whose `tools/list_changed` the entry's +listen router fans onto every open subscription. + +```bash +# stdio (the client spawns the server itself): +pnpm tsx examples/subscriptions/client.ts + +# Streamable HTTP (two terminals): +pnpm tsx examples/subscriptions/server.ts --http --port 3000 +pnpm tsx examples/subscriptions/client.ts --http http://127.0.0.1:3000/ +``` diff --git a/examples/subscriptions/client.ts b/examples/subscriptions/client.ts new file mode 100644 index 0000000000..44991257e0 --- /dev/null +++ b/examples/subscriptions/client.ts @@ -0,0 +1,76 @@ +/** + * Drives the `subscriptions/listen` server (`./server.ts`) two ways on a + * 2026-07-28 connection: + * + * 1. **auto-open via `ClientOptions.listChanged`** — the same option a + * 2025-era client sets; on a modern connection the SDK auto-opens a + * listen stream with the filter derived from which sub-options were set, + * so the configured `onChanged` handlers fire on every published change; + * 2. **manual `client.listen()`** — opens a stream explicitly, registers a + * `notifications/tools/list_changed` handler the stream feeds, and closes + * after a few notifications. + * + * The example calls `flip_tools` to mutate the server's tool set on demand + * (rather than a timer), then asserts the change notification arrived. + */ +import type { McpSubscription } from '@modelcontextprotocol/client'; + +import { check, connectFromArgs, runClient } from '../harness.js'; + +/** Wait until `pred()` is true or `timeoutMs` elapses. */ +async function until(pred: () => boolean, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (!pred()) { + if (Date.now() > deadline) throw new Error('timed out waiting for change notification'); + await new Promise(r => setTimeout(r, 25)); + } +} + +async function autoOpenLeg(): Promise { + let count = 0; + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname, { + listChanged: { + tools: { + autoRefresh: false, + // The default debounce coalesces bursts; this example asserts + // raw delivery, so disable it. + debounceMs: 0, + onChanged: () => void count++ + } + } + }); + check.ok(client.autoOpenedSubscription, 'a listChanged option should auto-open a subscription on a modern connection'); + check.ok(client.autoOpenedSubscription?.honoredFilter.toolsListChanged, 'auto-opened filter should include toolsListChanged'); + + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 1); + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 2); + + await client.autoOpenedSubscription?.close(); + await client.close(); + check.ok(count >= 2, 'auto-open leg should receive at least two tools/list_changed'); +} + +async function manualLeg(): Promise { + const client = await connectFromArgs(import.meta.dirname); + let count = 0; + client.setNotificationHandler('notifications/tools/list_changed', () => void count++); + const sub: McpSubscription = await client.listen({ toolsListChanged: true }); + check.ok(sub.honoredFilter.toolsListChanged, 'manual listen should honor toolsListChanged'); + + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 1); + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 2); + + await sub.close(); + await client.close(); + check.ok(count >= 2, 'manual leg should receive at least two tools/list_changed'); +} + +runClient('subscriptions', async () => { + await autoOpenLeg(); + await manualLeg(); +}); diff --git a/examples/subscriptions/package.json b/examples/subscriptions/package.json new file mode 100644 index 0000000000..8e1fda7df6 --- /dev/null +++ b/examples/subscriptions/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcp-examples/subscriptions", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "subscriptions/listen is a 2026-07-28 protocol feature." + } +} diff --git a/examples/subscriptions/server.ts b/examples/subscriptions/server.ts new file mode 100644 index 0000000000..fa85321421 --- /dev/null +++ b/examples/subscriptions/server.ts @@ -0,0 +1,89 @@ +/** + * `subscriptions/listen` change notifications (protocol revision 2026-07-28). + * + * One factory, either transport — but the publish surface differs by entry: + * + * - **HTTP** (`createMcpHandler`): the handler exposes `.notify` + * ({@link ServerNotifier}) over its cross-request {@link ServerEventBus}; + * `handler.notify.toolsChanged()` reaches every open `subscriptions/listen` + * stream that opted in to `toolsListChanged`. + * - **stdio** (`serveStdio`): one `McpServer` instance is pinned for the + * connection; toggling a `RegisteredTool` (`.enable()/.disable()`) emits the + * instance's own `notifications/tools/list_changed`, which the stdio entry's + * listen router fans onto every open subscription. + * + * The `flip_tools` tool toggles the `farewell` tool and publishes the change, + * so the client decides when to mutate (no timer race in the harness). The + * shared `runServerFromArgs` scaffold doesn't expose the handler/instance, so + * this example branches on `--http` itself (same flags, same factory). + */ +import { createServer } from 'node:http'; + +import type { RegisteredTool, ServerEventBus, ServerNotifier } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +let extraToolEnabled = false; +/** + * Publishes `tools/list_changed` to every open subscription. Assigned by the + * transport branch below: `handler.notify.toolsChanged()` over HTTP; toggling + * the pinned instance's `RegisteredTool` over stdio. + */ +let publish: () => void = () => {}; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'subscriptions-listen-example', version: '1.0.0' }, + { capabilities: { tools: { listChanged: true } } } + ); + + server.registerTool('greet', { description: 'Returns a greeting', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `hello, ${name}` }] + })); + const farewell: RegisteredTool = server.registerTool( + 'farewell', + { description: 'Returns a farewell', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `goodbye, ${name}` }] }) + ); + if (!extraToolEnabled) farewell.disable(); + + server.registerTool( + 'flip_tools', + { description: 'Toggle the farewell tool and publish tools/list_changed to every open subscription' }, + async () => { + extraToolEnabled = !extraToolEnabled; + // Over stdio this `update` IS the publish (the entry's listen + // router fans the instance's outbound list_changed onto every open + // subscription); over HTTP it just keeps this per-request instance + // consistent and `publish()` reaches the cross-request bus. + farewell.update({ enabled: extraToolEnabled }); + publish(); + return { content: [{ type: 'text', text: `farewell ${extraToolEnabled ? 'enabled' : 'disabled'}` }] }; + } + ); + + return server; +} + +const argv = process.argv.slice(2); +if (argv.includes('--http')) { + const portIdx = argv.indexOf('--port'); + const port = portIdx === -1 ? Number(process.env.PORT ?? 3000) : Number(argv[portIdx + 1]); + // Host with the per-request HTTP entry on its default posture. The handler + // creates an in-process bus by default; supply your own `bus` for + // multi-process deployments. + const handler = createMcpHandler(buildServer); + const bus: ServerEventBus = handler.bus; + const notify: ServerNotifier = handler.notify; + void bus; // (the typed publish facade `notify` wraps `bus.publish`) + publish = () => notify.toolsChanged(); + createServer((req, res) => void handler.node(req, res)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/ (HTTP)`); + }); +} else { + // Over stdio the per-instance `farewell.update` inside `flip_tools` IS the + // publish, so `publish` stays a no-op here. + serveStdio(buildServer); + console.error('[server] serving over stdio'); +} diff --git a/examples/tools/README.md b/examples/tools/README.md new file mode 100644 index 0000000000..6d0bf11784 --- /dev/null +++ b/examples/tools/README.md @@ -0,0 +1,8 @@ +# tools + +**Start here.** Register tools with `McpServer.registerTool`; the SDK infers the JSON Schema from any Standard-Schema-compatible input (Zod here) and emits `structuredContent` matching `outputSchema`. The client lists tools, inspects schemas and `annotations`, calls them, and +asserts structured output. + +```bash +pnpm tsx examples/tools/client.ts +``` diff --git a/examples/tools/client.ts b/examples/tools/client.ts new file mode 100644 index 0000000000..0bed08d2fa --- /dev/null +++ b/examples/tools/client.ts @@ -0,0 +1,40 @@ +/** + * Drives the tools example: list, inspect schemas + annotations, call, + * assert structured output, assert an unknown tool errors. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('tools', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname); + + const list = await client.listTools(); + const names = new Set(list.tools.map(t => t.name)); + check.ok(names.has('calc') && names.has('echo'), 'tools/list should contain calc and echo'); + + const calc = list.tools.find(t => t.name === 'calc')!; + check.equal(calc.annotations?.readOnlyHint, true); + const required = (calc.inputSchema as { required?: string[] }).required ?? []; + check.ok(required.includes('op') && required.includes('a') && required.includes('b')); + check.ok(calc.outputSchema, 'calc should publish an outputSchema'); + + const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } }); + check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5); + check.equal((result.structuredContent as { op?: string } | undefined)?.op, 'add'); + + const echo = await client.callTool({ name: 'echo', arguments: { text: 'hi' } }); + check.equal(echo.content?.[0]?.type === 'text' ? echo.content[0].text : '', 'hi'); + check.equal(echo.structuredContent, undefined); + + // An unknown tool should be a tool error (isError) or a wire error — either is acceptable. + let unknownFailed = false; + try { + const r = await client.callTool({ name: 'nope', arguments: {} }); + unknownFailed = !!r.isError; + } catch { + unknownFailed = true; + } + check.ok(unknownFailed, 'calling an unknown tool should fail'); + + await client.close(); +}); diff --git a/examples/tools/package.json b/examples/tools/package.json new file mode 100644 index 0000000000..7c0791890d --- /dev/null +++ b/examples/tools/package.json @@ -0,0 +1,16 @@ +{ + "name": "@mcp-examples/tools", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/tools/server.ts b/examples/tools/server.ts new file mode 100644 index 0000000000..2987ba6e71 --- /dev/null +++ b/examples/tools/server.ts @@ -0,0 +1,50 @@ +/** + * Tools primitive — start here. + * + * Register tools with `McpServer.registerTool`: typed input via any + * Standard-Schema-with-JSON library (Zod here), inferred output schema + + * `structuredContent` from `outputSchema`, `annotations` for behavioral hints + * (`readOnlyHint`, `destructiveHint`). One binary, either transport. + */ +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'tools-example', version: '1.0.0' }); + + // A read-only tool with typed input and inferred structured output. + server.registerTool( + 'calc', + { + title: 'Calculator', + description: 'Apply an arithmetic operation to two numbers', + inputSchema: z.object({ + op: z.enum(['add', 'sub', 'mul']).describe('the operation to apply'), + a: z.number().describe('left operand'), + b: z.number().describe('right operand') + }), + outputSchema: z.object({ op: z.string(), result: z.number() }), + annotations: { readOnlyHint: true, idempotentHint: true } + }, + async ({ op, a, b }) => { + const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b; + const structuredContent = { op, result }; + return { content: [{ type: 'text', text: `${a} ${op} ${b} = ${result}` }], structuredContent }; + } + ); + + // A plain string-returning tool (no structuredContent). + server.registerTool( + 'echo', + { description: 'Echoes the input', inputSchema: z.object({ text: z.string() }) }, + async ({ text }): Promise => ({ content: [{ type: 'text', text }] }) + ); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/server/tsconfig.json b/examples/tsconfig.json similarity index 68% rename from examples/server/tsconfig.json rename to examples/tsconfig.json index 37a3e874f7..f8f4ab184e 100644 --- a/examples/server/tsconfig.json +++ b/examples/tsconfig.json @@ -1,13 +1,17 @@ { "extends": "@modelcontextprotocol/tsconfig", "include": ["./"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "shared", "server-quickstart", "client-quickstart"], "compilerOptions": { + "noEmit": true, "paths": { "*": ["./*"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], @@ -17,9 +21,7 @@ "@modelcontextprotocol/core/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" ], - "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"], - "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], - "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"] } } } diff --git a/package.json b/package.json index 03c4132988..ab5510cfa1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "fetch:spec-examples": "tsx scripts/fetch-spec-examples.ts", "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", "sync:snippets": "tsx scripts/sync-snippets.ts", - "examples:simple-server:w": "pnpm --filter @modelcontextprotocol/examples-server exec tsx --watch src/simpleStreamableHttp.ts --oauth", + "examples:oauth-server:w": "pnpm --filter @mcp-examples/oauth exec tsx --watch server.ts", + "run:examples": "tsx scripts/run-examples.ts", "docs": "typedoc", "docs:multi": "bash scripts/generate-multidoc.sh", "docs:check": "typedoc", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 483ebc939c..28443dbaaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,36 +293,101 @@ importers: specifier: catalog:devTools version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) - examples/client: + examples: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:^ + version: link:shared '@modelcontextprotocol/client': specifier: workspace:^ - version: link:../../packages/client + version: link:../packages/client + '@modelcontextprotocol/express': + specifier: workspace:^ + version: link:../packages/middleware/express + '@modelcontextprotocol/hono': + specifier: workspace:^ + version: link:../packages/middleware/hono + '@modelcontextprotocol/node': + specifier: workspace:^ + version: link:../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../packages/server + '@valibot/to-json-schema': + specifier: catalog:devTools + version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) ajv: specifier: catalog:runtimeShared version: 8.18.0 + arktype: + specifier: catalog:devTools + version: 2.2.0 + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + hono: + specifier: catalog:runtimeServerOnly + version: 4.12.9 open: specifier: ^11.0.0 version: 11.0.0 + valibot: + specifier: catalog:devTools + version: 1.3.1(typescript@5.9.3) zod: specifier: catalog:runtimeShared version: 4.3.6 devDependencies: '@modelcontextprotocol/eslint-config': specifier: workspace:^ - version: link:../../common/eslint-config - '@modelcontextprotocol/examples-shared': - specifier: workspace:^ - version: link:../shared + version: link:../common/eslint-config '@modelcontextprotocol/tsconfig': specifier: workspace:^ - version: link:../../common/tsconfig - '@modelcontextprotocol/vitest-config': - specifier: workspace:^ - version: link:../../common/vitest-config - tsdown: + version: link:../common/tsconfig + '@types/cors': specifier: catalog:devTools - version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + version: 2.8.19 + '@types/express': + specifier: catalog:devTools + version: 5.0.6 + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/bearer-auth: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/caching: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 examples/client-quickstart: dependencies: @@ -340,69 +405,303 @@ importers: specifier: catalog:devTools version: 5.9.3 - examples/server: + examples/custom-methods: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/custom-version: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/dual-era: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/elicitation: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/hono: dependencies: '@hono/node-server': specifier: catalog:runtimeServerOnly version: 1.19.11(hono@4.12.9) - '@modelcontextprotocol/examples-shared': - specifier: workspace:^ - version: link:../shared - '@modelcontextprotocol/express': - specifier: workspace:^ - version: link:../../packages/middleware/express + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client '@modelcontextprotocol/hono': - specifier: workspace:^ + specifier: workspace:* version: link:../../packages/middleware/hono - '@modelcontextprotocol/node': - specifier: workspace:^ - version: link:../../packages/middleware/node '@modelcontextprotocol/server': - specifier: workspace:^ + specifier: workspace:* version: link:../../packages/server - '@valibot/to-json-schema': + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: specifier: catalog:devTools - version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) - arktype: + version: 4.21.0 + + examples/json-response: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: specifier: catalog:devTools - version: 2.2.0 - better-auth: - specifier: ^1.4.17 - version: 1.5.6(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.21.0 + + examples/legacy-routing: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server cors: specifier: catalog:runtimeServerOnly version: 2.8.6 express: specifier: catalog:runtimeServerOnly version: 5.2.1 - hono: - specifier: catalog:runtimeServerOnly - version: 4.12.9 - valibot: + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@types/cors': specifier: catalog:devTools - version: 1.3.1(typescript@5.9.3) + version: 2.8.19 + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/mrtr: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/oauth: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + open: + specifier: ^11.0.0 + version: 11.0.0 zod: specifier: catalog:runtimeShared version: 4.3.6 devDependencies: - '@modelcontextprotocol/eslint-config': - specifier: workspace:^ - version: link:../../common/eslint-config - '@modelcontextprotocol/tsconfig': - specifier: workspace:^ - version: link:../../common/tsconfig - '@modelcontextprotocol/vitest-config': - specifier: workspace:^ - version: link:../../common/vitest-config '@types/cors': specifier: catalog:devTools version: 2.8.19 + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/oauth-client-credentials: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/parallel-calls: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/prompts: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/repl: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + ajv: + specifier: catalog:runtimeShared + version: 8.18.0 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: '@types/express': specifier: catalog:devTools version: 5.0.6 - tsdown: + tsx: specifier: catalog:devTools - version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + version: 4.21.0 + + examples/resources: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/sampling: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/schema-validators: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + '@valibot/to-json-schema': + specifier: catalog:devTools + version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) + arktype: + specifier: catalog:devTools + version: 2.2.0 + valibot: + specifier: catalog:devTools + version: 1.3.1(typescript@5.9.3) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 examples/server-quickstart: dependencies: @@ -496,6 +795,130 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + examples/sse-polling: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/standalone-get: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/stateless-legacy: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/stickynotes: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/streaming: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/subscriptions: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/tools: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + packages/client: dependencies: cross-spawn: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e15c6b22b8..3923d58ef0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,8 @@ packages: - packages/**/* - '!packages/codemod/batch-test/**' - common/**/* + - examples + - examples/* - examples/**/* - test/**/* diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts new file mode 100644 index 0000000000..71833f02c8 --- /dev/null +++ b/scripts/run-examples.ts @@ -0,0 +1,214 @@ +#!/usr/bin/env tsx +/** + * Build-and-e2e-run every story under `examples/` over every transport × era + * leg it supports. Each story's `client.ts` is a self-verifying e2e test (it + * asserts the server's behaviour and exits non-zero on any mismatch). + * + * - **stdio** (default for dual-transport stories): run `client.ts` with no + * transport flag; it spawns the sibling server binary itself and speaks + * MCP over the pipe. + * - **HTTP**: start `server.ts --http --port

`, poll until ready, run + * `client.ts --http http://127.0.0.1:

/`, kill the server. + * - **modern** (default): the client negotiates the 2026-07-28 era + * (`versionNegotiation: { mode: 'auto' }`). + * - **legacy**: pass `--legacy` to the client so it uses the 2025 + * `initialize` handshake (`versionNegotiation: { mode: 'legacy' }`). + * + * Per-story configuration lives in the story's `package.json` under the + * `"example"` field — most stories have none. `excluded` stories are listed + * (with their reason) but not run. Stories without a `client.ts` are skipped. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { connect } from 'node:net'; +import { join, resolve } from 'node:path'; + +type Era = 'modern' | 'legacy'; +type Transport = 'stdio' | 'http'; + +interface ExampleConfig { + /** Transports to run (default: `['stdio', 'http']`). */ + transports?: Transport[]; + /** `'dual'` (modern + legacy; the default), `'modern'`, or `'legacy'`. */ + era?: 'dual' | Era; + /** HTTP port (default: a per-story port assigned below). */ + port?: number; + /** Endpoint path (default: `'/mcp'`). */ + path?: string; + /** Extra environment for the server process. */ + env?: Record; + /** Per-leg timeout in milliseconds (default: 30000). */ + timeoutMs?: number; + /** Optional substring the client's stdout must contain. */ + expects?: { stdout?: string }; + /** When present, the story is skipped (with this reason printed). */ + excluded?: string; +} + +const ROOT = resolve(import.meta.dirname, '..'); +const EXAMPLES = join(ROOT, 'examples'); +const TSX = join(ROOT, 'node_modules', '.bin', 'tsx'); + +/** Directories that are never stories. */ +const NON_STORY = new Set(['shared', 'guides', 'server-quickstart', 'client-quickstart', 'node_modules']); + +/** Distinct per-story HTTP ports so the servers never collide. */ +let nextPort = 8530; +const portFor = new Map(); +function assignPort(story: string, config: ExampleConfig): number { + if (config.port) return config.port; + if (!portFor.has(story)) portFor.set(story, nextPort++); + return portFor.get(story)!; +} + +function readConfig(dir: string): ExampleConfig { + const file = join(dir, 'package.json'); + if (!existsSync(file)) return {}; + const pkg = JSON.parse(readFileSync(file, 'utf8')) as { example?: ExampleConfig }; + return pkg.example ?? {}; +} + +function run( + cmd: string, + args: string[], + opts: { cwd: string; env?: Record; timeoutMs: number } +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise(resolvePromise => { + const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env } }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', d => (stdout += String(d))); + child.stderr.on('data', d => (stderr += String(d))); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + resolvePromise({ code: 124, stdout, stderr: stderr + '\n[harness] timed out' }); + }, opts.timeoutMs); + child.on('close', code => { + clearTimeout(timer); + resolvePromise({ code: code ?? 1, stdout, stderr }); + }); + child.on('error', err => { + clearTimeout(timer); + resolvePromise({ code: 1, stdout, stderr: stderr + `\n[harness] spawn error: ${err.message}` }); + }); + }); +} + +async function waitForPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const ok = await new Promise(resolvePromise => { + const sock = connect({ port, host: '127.0.0.1' }, () => { + sock.destroy(); + resolvePromise(true); + }); + sock.on('error', () => resolvePromise(false)); + }); + if (ok) return true; + await new Promise(r => setTimeout(r, 200)); + } + return false; +} + +interface LegResult { + story: string; + leg: string; + ok: boolean; + detail: string; +} + +const eraArgs = (era: Era): string[] => (era === 'legacy' ? ['--legacy'] : []); + +async function runStdioLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise { + const timeoutMs = config.timeoutMs ?? 30_000; + const result = await run(TSX, [join(dir, 'client.ts'), ...eraArgs(era)], { cwd: ROOT, timeoutMs }); + const ok = result.code === 0 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout)); + return { + story, + leg: `stdio/${era}`, + ok, + detail: ok ? (result.stdout.trim().split('\n').pop() ?? '') : `exit ${result.code}\n${result.stderr || result.stdout}` + }; +} + +async function runHttpLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise { + const timeoutMs = config.timeoutMs ?? 30_000; + const port = assignPort(story, config); + const path = config.path ?? '/mcp'; + const url = `http://127.0.0.1:${port}${path}`; + let serverStderr = ''; + const server: ChildProcess = spawn(TSX, [join(dir, 'server.ts'), '--http', '--port', String(port)], { + cwd: ROOT, + env: { ...process.env, PORT: String(port), ...config.env } + }); + server.stderr?.on('data', d => (serverStderr += String(d))); + server.stdout?.on('data', d => (serverStderr += String(d))); + try { + const ready = await waitForPort(port, 15_000); + if (!ready) { + return { story, leg: `http/${era}`, ok: false, detail: `server never bound :${port}\n--- server log ---\n${serverStderr}` }; + } + const result = await run(TSX, [join(dir, 'client.ts'), '--http', url, ...eraArgs(era)], { cwd: ROOT, timeoutMs }); + const ok = result.code === 0 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout)); + return { + story, + leg: `http/${era}`, + ok, + detail: ok + ? (result.stdout.trim().split('\n').pop() ?? '') + : `exit ${result.code}\n${result.stderr || result.stdout}\n--- server log ---\n${serverStderr}` + }; + } finally { + server.kill('SIGTERM'); + await new Promise(r => setTimeout(r, 100)); + // `.killed` flips true the moment kill() is called, so it can't gate + // the backstop; check whether the process actually exited instead. + if (server.exitCode === null && server.signalCode === null) server.kill('SIGKILL'); + } +} + +async function main(): Promise { + const stories = readdirSync(EXAMPLES, { withFileTypes: true }) + .filter(d => d.isDirectory() && !NON_STORY.has(d.name)) + .map(d => d.name) + .filter(name => existsSync(join(EXAMPLES, name, 'client.ts'))) + .sort(); + + const results: LegResult[] = []; + const excluded: Array<{ story: string; reason: string }> = []; + + for (const story of stories) { + const dir = join(EXAMPLES, story); + const config = readConfig(dir); + if (config.excluded) { + excluded.push({ story, reason: config.excluded }); + console.log(`\n::group::example ${story}\nSKIPPED: ${config.excluded}\n::endgroup::`); + continue; + } + const transports: Transport[] = config.transports ?? ['stdio', 'http']; + const era = config.era ?? 'dual'; + const eras: Era[] = era === 'dual' ? ['modern', 'legacy'] : [era]; + console.log(`\n::group::example ${story} (${transports.join('+')} × ${era})`); + for (const t of transports) { + for (const e of eras) { + const r = t === 'stdio' ? await runStdioLeg(story, dir, config, e) : await runHttpLeg(story, dir, config, e); + results.push(r); + console.log(`[${r.leg}] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`); + if (!r.ok) console.log(r.detail); + } + } + console.log('::endgroup::'); + } + + const passed = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok); + console.log('\n=== examples e2e summary ==='); + console.log(`stories: ${stories.length - excluded.length} run / ${excluded.length} excluded`); + console.log(`legs: ${passed} passed / ${failed.length} failed`); + for (const r of failed) console.log(` FAIL ${r.story} [${r.leg}]`); + for (const e of excluded) console.log(` SKIP ${e.story}: ${e.reason}`); + + process.exit(failed.length === 0 ? 0 : 1); +} + +void main(); diff --git a/typedoc.config.mjs b/typedoc.config.mjs index f2a4e50f56..675e3656b3 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -32,7 +32,7 @@ export default { exclude: ['**/*.examples.ts'] }, highlightLanguages: [...OptionDefaults.highlightLanguages, 'powershell'], - projectDocuments: ['docs/documents.md', 'packages/middleware/README.md', 'examples/server/README.md', 'examples/client/README.md'], + projectDocuments: ['docs/documents.md', 'packages/middleware/README.md', 'examples/README.md'], hostedBaseUrl: 'https://ts.sdk.modelcontextprotocol.io/v2/', navigationLinks: { 'V1 Docs': '/' From d8ed08132b403c25c000ed8b9b335cf5e474607b Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:43:33 +0100 Subject: [PATCH 34/37] ci: declare read-only GITHUB_TOKEN permissions on the examples workflow (#2329) --- .github/workflows/examples.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 8fbb86063a..9426afd78f 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -8,6 +8,9 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true From 0e6a6249100b54d08ad6df3db070409960008415 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:16:56 +0100 Subject: [PATCH 35/37] feat(client): minimal response-cache substrate (ResponseCacheStore + aggregate-then-write list*()) (#2336) --- .changeset/client-response-cache-substrate.md | 7 + docs/client.md | 44 +- docs/migration-SKILL.md | 4 + docs/migration.md | 26 ++ examples/guides/clientGuide.examples.ts | 32 +- packages/client/src/client/client.examples.ts | 44 +- packages/client/src/client/client.ts | 335 +++++++++++--- packages/client/src/client/responseCache.ts | 308 +++++++++++++ packages/client/src/index.ts | 2 + .../jsonSchemaValidatorOverride.test.ts | 23 +- .../client/test/client/responseCache.test.ts | 429 ++++++++++++++++++ packages/core/src/errors/sdkErrors.ts | 9 + .../core/test/types/errorSurfacePins.test.ts | 1 + test/e2e/requirements.ts | 32 +- test/e2e/scenarios/pagination.test.ts | 23 +- test/e2e/scenarios/prompts.test.ts | 51 +-- test/e2e/scenarios/resources.test.ts | 94 ++-- test/e2e/scenarios/tools.test.ts | 54 +-- test/e2e/scenarios/validation.test.ts | 12 +- 19 files changed, 1193 insertions(+), 337 deletions(-) create mode 100644 .changeset/client-response-cache-substrate.md create mode 100644 packages/client/src/client/responseCache.ts create mode 100644 packages/client/test/client/responseCache.test.ts diff --git a/.changeset/client-response-cache-substrate.md b/.changeset/client-response-cache-substrate.md new file mode 100644 index 0000000000..16a0621cac --- /dev/null +++ b/.changeset/client-response-cache-substrate.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/client': major +--- + +`Client.listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` now **auto-aggregate every page** when called without a `cursor` and return the complete result with `nextCursor: undefined` (matching the C#, Java, and mcp.d SDKs). Pass an explicit `{ cursor }` string to fetch a single page; the per-page path is unchanged. Existing manual pagination loops keep working — the first iteration returns everything and the loop exits — but can be deleted. The aggregated result is written to the new pluggable `ResponseCacheStore` (default: a fresh per-instance `InMemoryResponseCacheStore`); a `ClientResponseCache` collaborator owns the eviction-generation guard and the derived `tools/list` index that `callTool`'s output validation and SEP-2243 `Mcp-Param-*` mirroring read. New exports: `ResponseCacheStore`, `CacheKey`, `CacheEntry`, `CacheScope`, `MaybePromise`, `InMemoryResponseCacheStore`; new `ClientOptions.responseCacheStore` / `ClientOptions.listMaxPages` (caps the auto-aggregate walk at 64 pages by default; throws `SdkError` with `SdkErrorCode.ListPaginationExceeded` on overrun so a partial aggregate is never cached). The store interface is async-ready (`MaybePromise<…>`); the in-memory default stays synchronous. **A store instance must not be shared across `Client` instances at all in v2.0.x** — entries are keyed by method only (server-identity confusion + `clear()`/`evict()` cross-talk); per-principal partitioning that enables safe sharing arrives with the full caching engine. + +**Behavior change (every era):** output-schema validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()` — and non-throwing: an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only (previously `listTools()` threw). A pluggable `jsonSchemaValidator` provider therefore observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at the validator-lifecycle level. diff --git a/docs/client.md b/docs/client.md index 6b7c0df110..79ddd12157 100644 --- a/docs/client.md +++ b/docs/client.md @@ -13,7 +13,7 @@ A client connects to a server, discovers what it offers — tools, resources, pr The examples below use these imports. Adjust based on which features and transport you need: ```ts source="../examples/guides/clientGuide.examples.ts#imports" -import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client'; +import type { AuthProvider } from '@modelcontextprotocol/client'; import { applyMiddlewares, Client, @@ -252,20 +252,14 @@ For manual control over the token exchange steps, use the Layer 2 utilities from Tools are callable actions offered by servers — discovering and invoking them is usually how your client enables an LLM to take action (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. Results may be paginated — loop on `nextCursor` to collect -all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. `listTools()` walks every page on your behalf and returns +the complete list (pass an explicit `{ cursor }` for per-page control): ```ts source="../examples/guides/clientGuide.examples.ts#callTool_basic" -const allTools: Tool[] = []; -let toolCursor: string | undefined; -do { - const { tools, nextCursor } = await client.listTools({ cursor: toolCursor }); - allTools.push(...tools); - toolCursor = nextCursor; -} while (toolCursor); +const { tools } = await client.listTools(); console.log( 'Available tools:', - allTools.map(t => t.name) + tools.map(t => t.name) ); const result = await client.callTool({ @@ -311,20 +305,14 @@ console.log(result.content); Resources are read-only data — files, database schemas, configuration — that your application can retrieve from a server and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. Results may be paginated — loop on -`nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. `listResources()` walks every page on your +behalf and returns the complete list (pass an explicit `{ cursor }` for per-page control): ```ts source="../examples/guides/clientGuide.examples.ts#readResource_basic" -const allResources: Resource[] = []; -let resourceCursor: string | undefined; -do { - const { resources, nextCursor } = await client.listResources({ cursor: resourceCursor }); - allResources.push(...resources); - resourceCursor = nextCursor; -} while (resourceCursor); +const { resources } = await client.listResources(); console.log( 'Available resources:', - allResources.map(r => r.name) + resources.map(r => r.name) ); const { contents } = await client.readResource({ uri: 'config://app' }); @@ -357,20 +345,14 @@ await client.unsubscribeResource({ uri: 'config://app' }); Prompts are reusable message templates that servers offer to help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. Results may be paginated — loop on -`nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. `listPrompts()` walks every page on +your behalf and returns the complete list (pass an explicit `{ cursor }` for per-page control): ```ts source="../examples/guides/clientGuide.examples.ts#getPrompt_basic" -const allPrompts: Prompt[] = []; -let promptCursor: string | undefined; -do { - const { prompts, nextCursor } = await client.listPrompts({ cursor: promptCursor }); - allPrompts.push(...prompts); - promptCursor = nextCursor; -} while (promptCursor); +const { prompts } = await client.listPrompts(); console.log( 'Available prompts:', - allPrompts.map(p => p.name) + prompts.map(p => p.name) ); const { messages } = await client.getPrompt({ diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 14d9330dfd..8b14aa8cc1 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -560,6 +560,10 @@ side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRound `Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead. +`Client.listTools()`, `listPrompts()`, `listResources()`, `listResourceTemplates()` called without a `cursor` now auto-aggregate every page and return the complete result (`nextCursor: undefined`); an explicit `{ cursor }` string still returns one page. Manual `do { … } while (cursor !== undefined)` loops keep working (the first call returns everything and the loop exits after one iteration) — replace them with the bare no-arg call. New `ClientOptions.listMaxPages` (default 64) caps the aggregate walk only; overrun throws `SdkError` (`SdkErrorCode.ListPaginationExceeded`). + +Output-schema validator compilation is now lazy (first `callTool()` against the cached `tools/list` entry) and non-throwing (an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only); `listTools()` no longer throws on an uncompilable `outputSchema`. Applies on every era — the legacy-era `listTools()` path is unchanged at the wire level only. + ### Server (Streamable HTTP transport) No code changes required; these are wire-behavior notes: diff --git a/docs/migration.md b/docs/migration.md index 2e79e8964b..7e74ad8843 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -553,6 +553,32 @@ const client = new Client( ); ``` +### Client list methods auto-aggregate pagination + +`Client.listTools()`, `listPrompts()`, `listResources()`, and `listResourceTemplates()` called **without a `cursor`** now walk every page on your behalf and return the complete aggregated result with `nextCursor: undefined`. This matches the C#, Java, and mcp.d SDKs. Passing an explicit `{ cursor }` string still fetches a single page (the v1 per-page contract). + +Existing manual pagination loops keep working — the first iteration returns everything and the loop exits after one pass — but they can now be deleted: + +```typescript +// v1 — manual pagination loop +const allTools: Tool[] = []; +let cursor: string | undefined; +do { + const { tools, nextCursor } = await client.listTools({ cursor }); + allTools.push(...tools); + cursor = nextCursor; +} while (cursor !== undefined); + +// v2 — auto-aggregated +const { tools } = await client.listTools(); +``` + +The auto-aggregate walk is capped at `ClientOptions.listMaxPages` pages (default 64; `0` disables) and throws an `SdkError` with `SdkErrorCode.ListPaginationExceeded` if the server's pagination does not converge, so a partial aggregate is never returned. The cap applies only to the no-`cursor` aggregate path; explicit per-page calls are never capped. The aggregated result is also written to the client's response cache (the source for `callTool`'s output-schema validation and SEP-2243 header mirroring). + +**Output-schema validator lifecycle (every era):** validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()` — and non-throwing: an uncompilable `outputSchema` is `console.warn`-ed +and validation is skipped for that tool only. In v1, `listTools()` threw on an uncompilable `outputSchema`; now it succeeds, and a pluggable `jsonSchemaValidator` provider observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is +unchanged at the wire level but is observably different at the validator-lifecycle level. + ### `InMemoryTransport` moved `InMemoryTransport` is now exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server` (both re-export it). It is still intended for in-process client-server connections and testing. diff --git a/examples/guides/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts index b44f78a223..68ef6015a3 100644 --- a/examples/guides/clientGuide.examples.ts +++ b/examples/guides/clientGuide.examples.ts @@ -8,7 +8,7 @@ */ //#region imports -import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client'; +import type { AuthProvider } from '@modelcontextprotocol/client'; import { applyMiddlewares, Client, @@ -196,16 +196,10 @@ async function auth_crossAppAccess(getIdToken: () => Promise) { /** Example: List and call tools. */ async function callTool_basic(client: Client) { //#region callTool_basic - const allTools: Tool[] = []; - let toolCursor: string | undefined; - do { - const { tools, nextCursor } = await client.listTools({ cursor: toolCursor }); - allTools.push(...tools); - toolCursor = nextCursor; - } while (toolCursor); + const { tools } = await client.listTools(); console.log( 'Available tools:', - allTools.map(t => t.name) + tools.map(t => t.name) ); const result = await client.callTool({ @@ -251,16 +245,10 @@ async function callTool_progress(client: Client) { /** Example: List and read resources. */ async function readResource_basic(client: Client) { //#region readResource_basic - const allResources: Resource[] = []; - let resourceCursor: string | undefined; - do { - const { resources, nextCursor } = await client.listResources({ cursor: resourceCursor }); - allResources.push(...resources); - resourceCursor = nextCursor; - } while (resourceCursor); + const { resources } = await client.listResources(); console.log( 'Available resources:', - allResources.map(r => r.name) + resources.map(r => r.name) ); const { contents } = await client.readResource({ uri: 'config://app' }); @@ -290,16 +278,10 @@ async function subscribeResource_basic(client: Client) { /** Example: List and get prompts. */ async function getPrompt_basic(client: Client) { //#region getPrompt_basic - const allPrompts: Prompt[] = []; - let promptCursor: string | undefined; - do { - const { prompts, nextCursor } = await client.listPrompts({ cursor: promptCursor }); - allPrompts.push(...prompts); - promptCursor = nextCursor; - } while (promptCursor); + const { prompts } = await client.listPrompts(); console.log( 'Available prompts:', - allPrompts.map(p => p.name) + prompts.map(p => p.name) ); const { messages } = await client.getPrompt({ diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index 0789b1501a..85f815c189 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -7,8 +7,6 @@ * @module */ -import type { Prompt, Resource, Tool } from '@modelcontextprotocol/core'; - import { Client } from './client.js'; import { SSEClientTransport } from './sse.js'; import { StdioClientTransport } from './stdio.js'; @@ -137,61 +135,43 @@ function Client_setRequestHandler_sampling(client: Client) { } /** - * Example: List tools with cursor-based pagination. + * Example: List tools (auto-aggregated across pages). */ async function Client_listTools_pagination(client: Client) { //#region Client_listTools_pagination - const allTools: Tool[] = []; - let cursor: string | undefined; - // Note: an empty-string cursor is valid and does not signal the end of results. - do { - const { tools, nextCursor } = await client.listTools({ cursor }); - allTools.push(...tools); - cursor = nextCursor; - } while (cursor !== undefined); + // No cursor → all pages aggregated for you. + const { tools } = await client.listTools(); console.log( 'Available tools:', - allTools.map(t => t.name) + tools.map(t => t.name) ); //#endregion Client_listTools_pagination } /** - * Example: List prompts with cursor-based pagination. + * Example: List prompts (auto-aggregated across pages). */ async function Client_listPrompts_pagination(client: Client) { //#region Client_listPrompts_pagination - const allPrompts: Prompt[] = []; - let cursor: string | undefined; - // Note: an empty-string cursor is valid and does not signal the end of results. - do { - const { prompts, nextCursor } = await client.listPrompts({ cursor }); - allPrompts.push(...prompts); - cursor = nextCursor; - } while (cursor !== undefined); + // No cursor → all pages aggregated for you. + const { prompts } = await client.listPrompts(); console.log( 'Available prompts:', - allPrompts.map(p => p.name) + prompts.map(p => p.name) ); //#endregion Client_listPrompts_pagination } /** - * Example: List resources with cursor-based pagination. + * Example: List resources (auto-aggregated across pages). */ async function Client_listResources_pagination(client: Client) { //#region Client_listResources_pagination - const allResources: Resource[] = []; - let cursor: string | undefined; - // Note: an empty-string cursor is valid and does not signal the end of results. - do { - const { resources, nextCursor } = await client.listResources({ cursor }); - allResources.push(...resources); - cursor = nextCursor; - } while (cursor !== undefined); + // No cursor → all pages aggregated for you. + const { resources } = await client.listResources(); console.log( 'Available resources:', - allResources.map(r => r.name) + resources.map(r => r.name) ); //#endregion Client_listResources_pagination } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index e6ae442893..a43aee20a3 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -78,6 +78,8 @@ import { SubscriptionFilterSchema } from '@modelcontextprotocol/core'; +import type { ResponseCacheStore } from './responseCache.js'; +import { ClientResponseCache, InMemoryResponseCacheStore } from './responseCache.js'; import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js'; import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation.js'; @@ -255,8 +257,50 @@ export type ClientOptions = ProtocolOptions & { * ``` */ listChanged?: ListChangedHandlers; + + /** + * Cap on the number of pages the auto-aggregating + * {@linkcode Client.listTools | listTools()} / + * {@linkcode Client.listPrompts | listPrompts()} / + * {@linkcode Client.listResources | listResources()} / + * {@linkcode Client.listResourceTemplates | listResourceTemplates()} walk + * fetches before throwing (a defence against a server whose `nextCursor` + * never converges). `0` disables the cap. Default: `64`. Applies only to + * the no-argument auto-aggregate path; an explicit-`cursor` per-page call + * is never capped. + */ + listMaxPages?: number; + + /** + * The response-cache store backing the client's derived views (the cached + * `tools/list` result that {@linkcode Client.callTool | callTool}'s output + * validation and SEP-2243 header mirroring will read once the stacked + * SEP-2243 PR lands; this commit ships only the seam). Defaults to a fresh + * {@linkcode InMemoryResponseCacheStore} per client. + * + * **Do not share one store across clients at all in v2.0.x** — entries + * are keyed by method + params only, so two clients connected to + * different servers (even under the same credential) collide on + * `tools/list`, and one client's `list_changed` evicts every co-tenant's + * entry. Supply your own only as a single-client backing store. + * Per-principal partitioning that enables safe sharing is #39. + */ + responseCacheStore?: ResponseCacheStore; }; +/** + * `list_changed` notification → response-cache method(s) to evict. `resources` + * covers both list verbs (the spec's "relevant notification ⇒ immediately + * stale"). + */ +const LIST_CHANGED_EVICTIONS: Readonly> = { + 'notifications/tools/list_changed': ['tools/list'], + 'notifications/prompts/list_changed': ['prompts/list'], + 'notifications/resources/list_changed': ['resources/list', 'resources/templates/list'] +}; + +const DEFAULT_LIST_MAX_PAGES = 64; + /** * A handle to an open `subscriptions/listen` stream (protocol revision * 2026-07-28). Change notifications delivered on the stream dispatch to the @@ -339,7 +383,20 @@ export class Client extends Protocol { private _capabilities: ClientCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; - private _cachedToolOutputValidators: Map> = new Map(); + /** + * The response-cache substrate. Owns the backing store, the per-method + * eviction-generation counter, the user-supplied/default flag, and the + * stamp-memoized derived `name → Tool` / `name → output-validator` + * indices — the cache-coordination state that used to live as separate + * private fields here. The internal aggregating walk writes one entry per + * list verb; `list_changed` evicts the matching method; + * `_resetConnectionState` resets the lot. {@linkcode callTool}'s + * output-schema validation reads the derived `outputValidator` index (the + * substrate's first production caller); the stacked SEP-2243 PR wires + * `Mcp-Param-*` mirroring through `toolDefinition` on top. + */ + private readonly _cache: ClientResponseCache; + private readonly _listMaxPages: number; private _listChangedDebounceTimers: Map> = new Map(); /** * The constructor `listChanged` configuration. Durable across reconnects: @@ -396,7 +453,12 @@ export class Client extends Protocol { clearTimeout(timer); } this._listChangedDebounceTimers.clear(); - this._cachedToolOutputValidators.clear(); + // A user-supplied store is NOT cleared on reconnect/close — that would + // defeat the only reason to supply one. The per-instance default IS + // cleared (it is connection-scoped); derived indices and the + // generation map are dropped regardless. The default impl is + // synchronous, so the call returns plain void here. + this._cache.resetForReconnect(); } override async close(): Promise { @@ -426,6 +488,15 @@ export class Client extends Protocol { // Multi-round-trip auto-fulfilment driver (2026-07-28): on by default, // configurable via ClientOptions.inputRequired. this._inputRequiredDriverConfig = resolveInputRequiredDriverConfig(options?.inputRequired); + // Response-cache substrate. A fresh in-memory store is allocated when + // the caller does not supply one — never share a default across + // instances (see ClientOptions.responseCacheStore). + this._cache = new ClientResponseCache( + options?.responseCacheStore ?? new InMemoryResponseCacheStore(), + options?.responseCacheStore !== undefined, + error => this._reportStoreError(error) + ); + this._listMaxPages = options?.listMaxPages ?? DEFAULT_LIST_MAX_PAGES; // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -1227,24 +1298,28 @@ export class Client extends Protocol { } /** - * Lists available prompts. Results may be paginated — loop on `nextCursor` to collect all pages. + * Lists available prompts. + * + * Called without a `cursor` (the common case), this walks every page and + * returns the complete aggregated list with `nextCursor: undefined`; the + * aggregate is also written to the {@linkcode ResponseCacheStore}. Pass an + * explicit `{ cursor }` to fetch a single page and walk pagination + * yourself — the per-page path returns the server's raw page (with + * `nextCursor` for the next call) and does not write the response cache. + * The auto-aggregate path is capped by + * {@linkcode ClientOptions | ClientOptions.listMaxPages} (default 64); the per-page path + * is not. * * Returns an empty list if the server does not advertise prompts capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). * * @example * ```ts source="./client.examples.ts#Client_listPrompts_pagination" - * const allPrompts: Prompt[] = []; - * let cursor: string | undefined; - * // Note: an empty-string cursor is valid and does not signal the end of results. - * do { - * const { prompts, nextCursor } = await client.listPrompts({ cursor }); - * allPrompts.push(...prompts); - * cursor = nextCursor; - * } while (cursor !== undefined); + * // No cursor → all pages aggregated for you. + * const { prompts } = await client.listPrompts(); * console.log( * 'Available prompts:', - * allPrompts.map(p => p.name) + * prompts.map(p => p.name) * ); * ``` */ @@ -1254,28 +1329,35 @@ export class Client extends Protocol { console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); return { prompts: [] }; } - return this.request({ method: 'prompts/list', params }, options); + if (params?.cursor !== undefined) { + return this.request({ method: 'prompts/list', params }, options); + } + return this._listAllPages('prompts/list', params, options, (acc, page) => acc.prompts.push(...page.prompts)); } /** - * Lists available resources. Results may be paginated — loop on `nextCursor` to collect all pages. + * Lists available resources. + * + * Called without a `cursor` (the common case), this walks every page and + * returns the complete aggregated list with `nextCursor: undefined`; the + * aggregate is also written to the {@linkcode ResponseCacheStore}. Pass an + * explicit `{ cursor }` to fetch a single page and walk pagination + * yourself — the per-page path returns the server's raw page (with + * `nextCursor` for the next call) and does not write the response cache. + * The auto-aggregate path is capped by + * {@linkcode ClientOptions | ClientOptions.listMaxPages} (default 64); the per-page path + * is not. * * Returns an empty list if the server does not advertise resources capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). * * @example * ```ts source="./client.examples.ts#Client_listResources_pagination" - * const allResources: Resource[] = []; - * let cursor: string | undefined; - * // Note: an empty-string cursor is valid and does not signal the end of results. - * do { - * const { resources, nextCursor } = await client.listResources({ cursor }); - * allResources.push(...resources); - * cursor = nextCursor; - * } while (cursor !== undefined); + * // No cursor → all pages aggregated for you. + * const { resources } = await client.listResources(); * console.log( * 'Available resources:', - * allResources.map(r => r.name) + * resources.map(r => r.name) * ); * ``` */ @@ -1285,11 +1367,22 @@ export class Client extends Protocol { console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); return { resources: [] }; } - return this.request({ method: 'resources/list', params }, options); + if (params?.cursor !== undefined) { + return this.request({ method: 'resources/list', params }, options); + } + return this._listAllPages('resources/list', params, options, (acc, page) => + acc.resources.push(...page.resources) + ); } /** - * Lists available resource URI templates for dynamic resources. Results may be paginated — see {@linkcode listResources | listResources()} for the cursor pattern. + * Lists available resource URI templates for dynamic resources. + * + * Called without a `cursor`, this walks every page and returns the + * complete aggregated list with `nextCursor: undefined`; the aggregate is + * also written to the {@linkcode ResponseCacheStore}. Pass an explicit + * `{ cursor }` to fetch a single page — see + * {@linkcode listResources | listResources()} for the per-page contract. * * Returns an empty list if the server does not advertise resources capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). @@ -1305,7 +1398,96 @@ export class Client extends Protocol { ); return { resourceTemplates: [] }; } - return this.request({ method: 'resources/templates/list', params }, options); + if (params?.cursor !== undefined) { + return this.request({ method: 'resources/templates/list', params }, options); + } + return this._listAllPages('resources/templates/list', params, options, (acc, page) => + acc.resourceTemplates.push(...page.resourceTemplates) + ); + } + + /** + * Walk every page of a paginated list verb, aggregate, and write ONE + * entry to the response cache. Internal — backs the public `list*` + * methods' no-`cursor` auto-aggregate path. Page 1's result object is + * mutated in place (its items array is extended; `nextCursor` is + * cleared); page-1 metadata (`ttlMs`, `cacheScope`, `_meta`) is preserved. + * A `nextCursor` that repeats stops the walk (defence against a + * non-converging server, mcp.d's `drainList` guard); + * {@linkcode ClientOptions.listMaxPages} is a hard cap — hitting it + * throws, so a partial aggregate is never cached. The + * captured-generation guard skips the write when a `list_changed` landed + * mid-walk, so the eviction is never overwritten by a stale aggregate. + * `finalize` runs on the complete aggregate before the cache write — the + * SEP-2243 invalid-`x-mcp-header` exclusion hooks here so the cached + * `tools/list` entry is already filtered. + * + * The caller's `baseParams` (everything except `cursor`) is threaded into + * every page request — page 1 sends `{...baseParams}`, later pages + * `{...baseParams, cursor}` — so a typed, documented `_meta` (e.g. W3C + * trace context) supplied to the public `list*()` reaches every wire + * request the walk issues. + */ + private async _listAllPages( + method: RequestMethod, + baseParams: { readonly [key: string]: unknown } | undefined, + options: RequestOptions | undefined, + append: (acc: R, page: R) => void, + finalize?: (acc: R) => void + ): Promise { + // Capture the eviction generation BEFORE page 1: a `list_changed` that + // lands mid-walk bumps the counter, and the terminal `write` skips + // when it observes the bump (the result still returns to the caller — + // it just is not cached). + const generation = this._cache.captureGeneration(method); + const acc = (await this.request({ method, ...(baseParams && { params: { ...baseParams } }) }, options)) as R; + let cursor = acc.nextCursor; + const seen = new Set(); + let pages = 1; + while (cursor !== undefined && !seen.has(cursor)) { + if (this._listMaxPages !== 0 && pages >= this._listMaxPages) { + throw new SdkError( + SdkErrorCode.ListPaginationExceeded, + `${method}: exceeded listMaxPages (${this._listMaxPages}); server pagination did not terminate`, + { method, listMaxPages: this._listMaxPages } + ); + } + seen.add(cursor); + const page = (await this.request({ method, params: { ...baseParams, cursor } }, options)) as R; + append(acc, page); + cursor = page.nextCursor; + pages++; + } + acc.nextCursor = undefined; + finalize?.(acc); + await this._cache.write(method, acc, generation); + return acc; + } + + /** Route a custom-store failure to `onerror` without aborting the surrounding dispatch. */ + private _reportStoreError(e: unknown): void { + this.onerror?.(e instanceof Error ? e : new Error(String(e))); + } + + /** + * Compile a single tool's `outputSchema` (or `undefined` when absent / + * uncompilable). Passed as the compile callback to + * {@linkcode ClientResponseCache.outputValidator} so the cache class stays + * free of any validator-provider dependency. One tool's uncompilable + * `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`) + * must not poison every other tool's `callTool` — warn naming the + * offender and return `undefined` so the validator index simply omits it. + */ + private _compileOutputValidator(tool: Tool): JsonSchemaValidator | undefined { + if (!tool.outputSchema) return undefined; + try { + return this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); + } catch (error) { + console.warn( + `[mcp-sdk] tool '${tool.name}': outputSchema failed to compile and will not be validated — ${error instanceof Error ? error.message : String(error)}` + ); + return undefined; + } } /** Reads the contents of a resource by URI. */ @@ -1542,6 +1724,28 @@ export class Client extends Protocol { * being silently swallowed. */ protected override _onnotification(raw: JSONRPCNotification, extra?: MessageExtraInfo): void { + // Response-cache invalidation: a `list_changed` notification means the + // matching cached list result is stale. Evict (do NOT refetch) before + // dispatch so a handler that reaches the cache observes the cleared + // entry. Runs regardless of whether the user + // configured `listChanged` — derived views (the tool index, output + // validators) must drop the stale entry either way. `raw.method` is + // server-controlled; guard with `Object.hasOwn` so an inherited + // `Object.prototype` member name (`constructor`, `toString`, …) does + // not reach the iteration as a non-iterable function. + const evicted = Object.hasOwn(LIST_CHANGED_EVICTIONS, raw.method) ? LIST_CHANGED_EVICTIONS[raw.method] : undefined; + if (evicted !== undefined) { + for (const method of evicted) { + // `evict()` bumps the generation FIRST and unconditionally + // (the `_cacheListResult` race guard relies on the bump, not + // on the store's evict completing), then awaits the store. A + // custom store's `evict()` may throw or reject; route to + // `onerror` and proceed so dispatch (and the user's + // `listChanged` handler) runs regardless. Fire-and-forget — + // dispatch must not block on an async store. + void this._cache.evict(method).catch(error => this._reportStoreError(error)); + } + } if (raw.method === 'notifications/subscriptions/acknowledged') { const params = raw.params as { _meta?: Record; notifications?: unknown } | undefined; const subscriptionId = params?._meta?.[SUBSCRIPTION_ID_META_KEY]; @@ -1664,8 +1868,22 @@ export class Client extends Protocol { // construction). const result = await this.request({ method: 'tools/call', params }, options); - // Check if the tool has an outputSchema - const validator = this.getToolOutputValidator(params.name); + // Check if the tool has an outputSchema. Reads the cached + // `tools/list` entry (via the response cache's stamp-memoized + // `outputValidator` index) — `callTool` never issues a `tools/list` + // itself; the cache is populated by the caller's own + // {@linkcode listTools | listTools()}. A cold cache means validation + // is skipped (the v1.x opportunistic behaviour, kept so a legacy/stdio + // `callTool` still issues zero extra requests). The cache read is + // guarded the same way as `evict()`/`set()`: a custom store whose + // `get()` rejects AFTER the server has already executed the call must + // not surface as a `callTool()` rejection (a caller that retries on + // failure would re-execute a possibly side-effecting tool). Route to + // `onerror` and degrade to skipping validation — the same outcome as + // a cold cache. + const validator = await this._cache + .outputValidator(params.name, tool => this._compileOutputValidator(tool)) + .catch(error => void this._reportStoreError(error)); if (validator) { // If tool has outputSchema, it MUST return structuredContent (unless it's an error) if (!result.structuredContent && !result.isError) { @@ -1703,47 +1921,29 @@ export class Client extends Protocol { } /** - * Cache validators for tool output schemas. - * Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance. - */ - private cacheToolMetadata(tools: Tool[]): void { - this._cachedToolOutputValidators.clear(); - - for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator - if (tool.outputSchema) { - const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); - this._cachedToolOutputValidators.set(tool.name, toolValidator); - } - } - } - - /** - * Get cached validator for a tool - */ - private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { - return this._cachedToolOutputValidators.get(toolName); - } - - /** - * Lists available tools. Results may be paginated — loop on `nextCursor` to collect all pages. + * Lists available tools. + * + * Called without a `cursor` (the common case), this walks every page and + * returns the complete aggregated list with `nextCursor: undefined`; the + * aggregate is also written to the {@linkcode ResponseCacheStore} (the + * source for {@linkcode callTool | callTool()}'s output-schema validation + * and SEP-2243 `Mcp-Param-*` header mirroring). Pass an explicit + * `{ cursor }` to fetch a single page and walk pagination yourself — the + * per-page path returns the server's raw page (with `nextCursor` for the + * next call) and does not write the response cache. The auto-aggregate + * path is capped by {@linkcode ClientOptions | ClientOptions.listMaxPages} (default 64); + * the per-page path is not. * * Returns an empty list if the server does not advertise tools capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). * * @example * ```ts source="./client.examples.ts#Client_listTools_pagination" - * const allTools: Tool[] = []; - * let cursor: string | undefined; - * // Note: an empty-string cursor is valid and does not signal the end of results. - * do { - * const { tools, nextCursor } = await client.listTools({ cursor }); - * allTools.push(...tools); - * cursor = nextCursor; - * } while (cursor !== undefined); + * // No cursor → all pages aggregated for you. + * const { tools } = await client.listTools(); * console.log( * 'Available tools:', - * allTools.map(t => t.name) + * tools.map(t => t.name) * ); * ``` */ @@ -1753,12 +1953,13 @@ export class Client extends Protocol { console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); return { tools: [] }; } - const result = await this.request({ method: 'tools/list', params }, options); - - // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); - - return result; + if (params?.cursor !== undefined) { + // Explicit-cursor per-page contract: return one page; do NOT touch + // the response cache (a single page is not the complete aggregate + // the derived `outputValidator` index keys against). + return await this.request({ method: 'tools/list', params }, options); + } + return this._listAllPages('tools/list', params, options, (acc, page) => acc.tools.push(...page.tools)); } /** diff --git a/packages/client/src/client/responseCache.ts b/packages/client/src/client/responseCache.ts new file mode 100644 index 0000000000..faa3f54195 --- /dev/null +++ b/packages/client/src/client/responseCache.ts @@ -0,0 +1,308 @@ +import type { ListToolsResult, Tool } from '@modelcontextprotocol/core'; + +/** + * Minimal response-cache substrate (the kernel of #39's design). + * + * The store is a dumb keyed-value carrier: every freshness, scope and + * invalidation decision lives in the {@linkcode ClientResponseCache} (the + * `Client`'s single cache-coordination collaborator). This file + * deliberately + * ships only what the SEP-2243 mirroring path and the existing + * `tools/list`-derived validators need today — the full `cacheHints` engine + * (TTL short-circuiting, public/private partitioning, `CacheMode`) lands with + * the rest of #39 on top of the same interface. + * + * Reference design: mcp.d `client/cache.d` / `client/client.d` (`CacheStore`, + * `cachedTool`). The `stamp` field is mcp.d's re-derivation key — a derived + * view (e.g. the `name → Tool` index) re-computes only when the backing + * entry's stamp changes. + */ + +/** A value or a promise of one. The store interface is async-ready; the in-memory default returns plain values. */ +export type MaybePromise = T | Promise; + +/** The freshness scope of a cached entry (#39's `cacheHints.scope`). */ +export type CacheScope = 'public' | 'private'; + +/** + * A logical cache address. `params` is the canonical result-affecting params + * key (`''` for the four list ops, the `uri` for `resources/read`); omitted is + * equivalent to `''`. `partition` is the per-principal identity slot reserved + * for #39's shared-store partitioning — always `''` today (the + * `Client` never populates it); omitted is equivalent to `''`. + */ +export interface CacheKey { + readonly method: string; + readonly params?: string; + readonly partition?: string; +} + +/** + * One cached response body. `value` is the verbatim decoded result; `stamp` is + * the store-generated monotonically increasing write counter — opaque to + * callers. Derived views (e.g. a `name → Tool` index) memoize against it and + * re-derive only when it changes. `expiresAt` and `scope` are the + * client-computed freshness metadata (#39 — `expiresAt = now + ttlMs`, + * `scope` from `cacheHints`); the substrate does not populate them yet, but + * the slot exists so a custom store written today persists them once #39 + * lands without a signature change. + */ +export interface CacheEntry { + readonly value: unknown; + readonly stamp: number; + readonly expiresAt?: number; + readonly scope?: CacheScope; +} + +/** + * The pluggable response-cache store. The interface is intentionally narrow; + * the in-memory default is the only implementation the SDK ships. + * + * Every method is async-ready ({@linkcode MaybePromise}) so a Redis-style + * store can implement the same interface without a later breaking change; the + * in-memory default stays synchronous (plain values are valid under + * `MaybePromise`). The `Client` `await`s every call site. + * + * **A store instance MUST NOT be shared across `Client` instances at + * all in v2.0.x.** Entries are keyed by method + params only (the + * `Client` never populates `partition` today), so two clients + * connected to different servers — even under the same credential — collide on + * `tools/list` (server-identity confusion); a `list_changed` from one server + * evicts every co-tenant's entry; and one client reconnecting drops the + * derived indices that read the shared store. The `Client` + * constructor always allocates a fresh {@linkcode InMemoryResponseCacheStore} + * when `responseCacheStore` is not supplied; pass your own only as a + * single-client backing store. Per-principal partitioning that enables safe + * sharing arrives with the full #39 `cacheHints` engine. + */ +export interface ResponseCacheStore { + get(key: CacheKey): MaybePromise; + /** + * Writes `entry` under `key` and returns the store-generated stamp the + * resulting {@linkcode CacheEntry} carries. The store owns the stamp + * counter; callers do not supply one. The caller owns `expiresAt` and + * `scope` (the client-computed freshness metadata; not yet populated by + * the substrate — #39 wires them); the store MUST persist them and hand + * them back on `get`. + */ + set(key: CacheKey, entry: { value: unknown; expiresAt?: number; scope?: CacheScope }): MaybePromise; + /** Drop every entry for `method` (the `list_changed` invalidation). */ + evict(method: string): MaybePromise; + /** Drop every entry (connection reset). */ + clear(): MaybePromise; +} + +/** + * In-memory default. Unbounded — the four list ops write at most one entry + * each, so a bound is not yet useful; the LRU cap arrives with `resources/read` + * caching in #39. + */ +export class InMemoryResponseCacheStore implements ResponseCacheStore { + private readonly _entries = new Map(); + private _stamp = 0; + + get(key: CacheKey): CacheEntry | undefined { + return this._entries.get(keyOf(key)); + } + + set(key: CacheKey, entry: { value: unknown; expiresAt?: number; scope?: CacheScope }): number { + const stamp = ++this._stamp; + this._entries.set(keyOf(key), { ...entry, stamp }); + return stamp; + } + + evict(method: string): void { + const prefix = `${method}\0`; + for (const k of this._entries.keys()) { + if (k.startsWith(prefix)) this._entries.delete(k); + } + } + + clear(): void { + this._entries.clear(); + } +} + +function keyOf(key: CacheKey): string { + return `${key.method}\0${key.partition ?? ''}\0${key.params ?? ''}`; +} + +/** + * The `Client`'s cache-coordination collaborator. + * + * Owns the per-connection cache state that used to live as five private + * fields on `Client` — the backing {@linkcode ResponseCacheStore}, the + * per-method eviction-generation counter, the user-supplied/default flag, and + * the stamp-memoized derived indices over the `tools/list` entry. `Client` + * holds exactly one instance and never reaches past it to the store. + * + * Not exported from the package index — internal to the client package. + * + * @internal + */ +export class ClientResponseCache { + /** + * Per-method eviction-generation counter. {@linkcode evict} bumps it before + * touching the store; {@linkcode captureGeneration} reads it before a list + * walk's page 1; {@linkcode write} skips when it moved — so a + * `list_changed` arriving mid-walk is not overwritten by the walk's stale + * aggregate. + */ + private readonly _evictionGeneration = new Map(); + /** + * `name → Tool` index derived from the cached `tools/list` entry, memoized + * against the entry's `stamp` so it re-derives only when the backing entry + * changes (mcp.d's `cachedTool` pattern). + */ + private _toolIndex?: { stamp: number; byName: Map }; + /** + * `name → compiled output-schema validator` derived from the cached + * `tools/list` entry; same stamp-keyed memoization as `_toolIndex`. Typed + * `unknown` so this class stays free of any validator-provider dependency + * — the compile callback supplied to {@linkcode outputValidator} owns the + * concrete type. + */ + private _toolOutputValidatorIndex?: { stamp: number; byName: Map }; + + constructor( + private readonly _store: ResponseCacheStore, + /** + * Whether `_store` was supplied by the caller. A user-supplied store is + * never `clear()`ed by {@linkcode resetForReconnect} (defeats the only + * reason to supply one). + */ + private readonly _isUserSupplied: boolean, + /** + * Sink for a custom store's `set()`/`evict()` failure. {@linkcode write} + * never lets a store rejection cost the caller a result it already + * fetched — the failure is reported here and the write resolves. The + * `Client` wires this to `onerror`. + */ + private readonly _reportError: (error: unknown) => void = () => {} + ) {} + + /** + * Bump the per-method generation (so an in-flight {@linkcode write} for the + * same method becomes a no-op) and evict the store entry. The generation + * bump is unconditional and FIRST — the {@linkcode write} race guard relies + * on the bump, not on the store's evict completing. A custom store's + * `evict()` may throw or reject; the caller routes that to `onerror`. + */ + async evict(method: string): Promise { + this._evictionGeneration.set(method, (this._evictionGeneration.get(method) ?? 0) + 1); + await this._store.evict(method); + } + + /** Snapshot the eviction generation for `method` before a list walk's page 1. */ + captureGeneration(method: string): number { + return this._evictionGeneration.get(method) ?? 0; + } + + /** + * Write `value` under `{method}` unless the per-method generation moved + * since `capturedGen` was taken — a `list_changed` that landed mid-walk has + * already invalidated the result the caller is about to write, and + * overwriting the eviction with the stale aggregate would lose the + * invalidation. + * + * The stored value is a `structuredClone` of `value`, so a caller + * mutating the aggregate it was returned (e.g. `result.tools.sort(...)`) + * cannot reach the cache or the stamp-memoized indices derived from it. A + * custom store whose `set()` throws or rejects is routed to the + * `reportError` sink and the write resolves — cache bookkeeping never + * costs the caller a result it already fetched (consistent with the + * eviction path). + */ + async write(method: string, value: unknown, capturedGen: number): Promise { + if ((this._evictionGeneration.get(method) ?? 0) !== capturedGen) return; + try { + await this._store.set({ method }, { value: structuredClone(value) }); + } catch (error) { + this._reportError(error); + } + } + + /** Read the cached entry for `{method}` (the four list verbs). */ + async read(method: string): Promise { + return this._store.get({ method }); + } + + /** + * Connection reset. The per-instance default store IS cleared + * (connection-scoped); a user-supplied store is NOT — that would defeat + * the only reason to supply one. The generation map and every derived + * index are dropped regardless: they are connection-scoped even when the + * backing store survives, so the next read re-derives from whatever the + * store still holds. The default impl is synchronous, so the + * `MaybePromise` return is a plain void here and the caller need not + * await. + */ + resetForReconnect(): void { + if (!this._isUserSupplied) void this._store.clear(); + this._evictionGeneration.clear(); + this._toolIndex = undefined; + this._toolOutputValidatorIndex = undefined; + } + + /** + * The descriptor for tool `name` taken from the cached `tools/list` entry. + * The `name → Tool` index is memoized against the entry's `stamp` and + * re-derived only when the backing entry changes (mcp.d's `cachedTool`). + * Returns `undefined` only when no `tools/list` response is held at all, + * or the held list does not contain `name`. + * + * No production caller in the substrate commit — the stacked SEP-2243 PR + * wires `callTool()`'s `Mcp-Param-*` mirroring through it. + * {@linkcode outputValidator} is the substrate's own derived view over the + * same entry. + */ + async toolDefinition(name: string): Promise { + const entry = await this._store.get({ method: 'tools/list' }); + if (entry === undefined) { + this._toolIndex = undefined; + return undefined; + } + if (this._toolIndex?.stamp !== entry.stamp) { + const byName = new Map(); + for (const tool of (entry.value as ListToolsResult).tools) byName.set(tool.name, tool); + this._toolIndex = { stamp: entry.stamp, byName }; + } + return this._toolIndex.byName.get(name); + } + + /** + * The compiled output-schema validator for tool `name`, derived from the + * cached `tools/list` entry — same source and same stamp-keyed + * memoization as {@linkcode toolDefinition}. The `name → validator` index + * re-derives only when the backing entry's stamp changes (a refetched + * `tools/list` recompiles; a `list_changed` eviction drops it). Returns + * `undefined` when no `tools/list` is held, the tool is absent, or it has + * no `outputSchema`. + * + * `compile` is the caller-supplied validator-compile callback (the + * `Client` passes its `_jsonSchemaValidator` wrapper) so this + * class carries no validator-provider dependency. One tool's uncompilable + * `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`) + * must not poison every other tool's `callTool` — the callback returns + * `undefined` (and warns naming the offender) for the bad one and the + * index simply omits it. + */ + async outputValidator(name: string, compile: (tool: Tool) => V | undefined): Promise { + const entry = await this._store.get({ method: 'tools/list' }); + if (entry === undefined) { + this._toolOutputValidatorIndex = undefined; + return undefined; + } + if (this._toolOutputValidatorIndex?.stamp !== entry.stamp) { + const byName = new Map(); + for (const tool of (entry.value as ListToolsResult).tools) { + if (tool.outputSchema) { + const validator = compile(tool); + if (validator !== undefined) byName.set(tool.name, validator); + } + } + this._toolOutputValidatorIndex = { stamp: entry.stamp, byName }; + } + return this._toolOutputValidatorIndex.byName.get(name) as V | undefined; + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7fe7acb958..de5814a8c2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -59,6 +59,8 @@ export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, Request export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess.js'; export type { LoggingOptions, Middleware, RequestLogger } from './client/middleware.js'; export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware.js'; +export type { CacheEntry, CacheKey, CacheScope, MaybePromise, ResponseCacheStore } from './client/responseCache.js'; +export { InMemoryResponseCacheStore } from './client/responseCache.js'; export type { SSEClientTransportOptions } from './client/sse.js'; export { SSEClientTransport, SseError } from './client/sse.js'; export type { VersionNegotiationMode, VersionNegotiationOptions, VersionNegotiationProbeOptions } from './client/versionNegotiation.js'; diff --git a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts index 2e38f618c5..87ff1e9055 100644 --- a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts +++ b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts @@ -48,6 +48,12 @@ async function connectInitializedClient(client: Client) { ] } } satisfies JSONRPCMessage); + } else if ('method' in message && 'id' in message && message.method === 'tools/call') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { content: [], structuredContent: { count: 42 } } + } satisfies JSONRPCMessage); } }; @@ -56,7 +62,7 @@ async function connectInitializedClient(client: Client) { } describe('client JSON Schema validator overrides', () => { - test('Client constructor uses a custom validator for tool output schema caching', async () => { + test('Client uses the custom validator for tool output validation (derived from the cached tools/list entry)', async () => { const validator = new RecordingValidator(); const client = new Client( { name: 'test-client', version: '1.0.0' }, @@ -67,6 +73,8 @@ describe('client JSON Schema validator overrides', () => { ); const { clientTransport, serverTransport } = await connectInitializedClient(client); + // The validator index reads the cached `tools/list` entry; populate it + // via the public auto-aggregating listTools(). await expect(client.listTools()).resolves.toMatchObject({ tools: [ { @@ -80,6 +88,14 @@ describe('client JSON Schema validator overrides', () => { ] }); + // Derived-view behavior: the validator index re-derives lazily on the + // first callTool against the cached entry's stamp — populating the + // cache alone does not compile. + expect(validator.schemas).toEqual([]); + + await expect(client.callTool({ name: 'structured-tool' })).resolves.toMatchObject({ + structuredContent: { count: 42 } + }); expect(validator.schemas).toEqual([ { type: 'object', @@ -87,6 +103,11 @@ describe('client JSON Schema validator overrides', () => { required: ['count'] } ]); + expect(validator.values).toEqual([{ count: 42 }]); + + // Same backing entry stamp → memoized; a second callTool does not recompile. + await client.callTool({ name: 'structured-tool' }); + expect(validator.schemas).toHaveLength(1); await client.close(); await clientTransport.close(); diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts new file mode 100644 index 0000000000..127be04bf8 --- /dev/null +++ b/packages/client/test/client/responseCache.test.ts @@ -0,0 +1,429 @@ +/** + * Response-cache substrate: store primitives, the {@linkcode ClientResponseCache} + * coordinator, and the Client's wiring (mcp.d's `cachedTool` pattern). + * + * Covers: `list*` auto-aggregation writing one entry; `list_changed` evicts + * (does not refetch); `resetForReconnect` respects the user-supplied flag; + * `toolDefinition` hit/miss and re-derivation only on a stamp change; the + * generation guard skipping a stale write. + */ +import type { JSONRPCMessage, JSONRPCRequest, Tool } from '@modelcontextprotocol/core'; +import { InMemoryTransport, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { ResponseCacheStore } from '../../src/client/responseCache.js'; +import { ClientResponseCache, InMemoryResponseCacheStore } from '../../src/client/responseCache.js'; + +const MODERN = '2026-07-28'; + +const TOOL_A: Tool = { name: 'a', inputSchema: { type: 'object', properties: {} } }; +const TOOL_B: Tool = { name: 'b', inputSchema: { type: 'object', properties: {} } }; + +describe('InMemoryResponseCacheStore', () => { + it('get/set/evict/clear round-trip; evict is method-scoped; set returns the store-generated stamp', () => { + const store = new InMemoryResponseCacheStore(); + const s1 = store.set({ method: 'tools/list' }, { value: 1 }); + const s2 = store.set({ method: 'prompts/list' }, { value: 2 }); + const s3 = store.set({ method: 'resources/read', params: 'file:///a' }, { value: 3, expiresAt: 123, scope: 'private' }); + // Store owns the stamp counter: monotonic, opaque to callers, surfaced on the entry. + expect(s2).toBeGreaterThan(s1); + expect(s3).toBeGreaterThan(s2); + expect(store.get({ method: 'tools/list' })).toEqual({ value: 1, stamp: s1 }); + // Store persists caller-supplied freshness metadata (#39 wires population; the slot exists today). + expect(store.get({ method: 'resources/read', params: 'file:///a' })).toEqual({ + value: 3, + stamp: s3, + expiresAt: 123, + scope: 'private' + }); + expect(store.get({ method: 'tools/list', params: '', partition: '' })?.value).toBe(1); + store.evict('tools/list'); + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + expect(store.get({ method: 'prompts/list' })?.value).toBe(2); + expect(store.get({ method: 'resources/read', params: 'file:///a' })?.value).toBe(3); + store.clear(); + expect(store.get({ method: 'prompts/list' })).toBeUndefined(); + }); + + it('partition is part of the key serialization (always empty today; #39 wires population)', () => { + const store = new InMemoryResponseCacheStore(); + store.set({ method: 'tools/list', partition: 'p1' }, { value: 'a' }); + store.set({ method: 'tools/list', partition: 'p2' }, { value: 'b' }); + expect(store.get({ method: 'tools/list', partition: 'p1' })?.value).toBe('a'); + expect(store.get({ method: 'tools/list', partition: 'p2' })?.value).toBe('b'); + // The Client never populates partition today, so the default-partition slot is distinct. + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + // evict(method) is partition-agnostic. + store.evict('tools/list'); + expect(store.get({ method: 'tools/list', partition: 'p1' })).toBeUndefined(); + expect(store.get({ method: 'tools/list', partition: 'p2' })).toBeUndefined(); + }); +}); + +describe('ClientResponseCache', () => { + it('write skips when the captured generation moved (list_changed-during-walk guard)', async () => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, false); + const gen = cache.captureGeneration('tools/list'); + await cache.evict('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, gen); + // Generation moved between capture and write → the stale aggregate is dropped. + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + // A fresh capture after the evict writes through. + const gen2 = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, gen2); + expect(store.get({ method: 'tools/list' })).toBeDefined(); + }); + + it('resetForReconnect: clears the default store, leaves a user-supplied store, ALWAYS drops generation + indices', async () => { + // User-supplied: store survives, generation map + derived index are dropped. + const userStore = new InMemoryResponseCacheStore(); + const userCache = new ClientResponseCache(userStore, true); + await userCache.write('tools/list', { tools: [TOOL_A] }, userCache.captureGeneration('tools/list')); + expect((await userCache.toolDefinition('a'))?.name).toBe('a'); + await userCache.evict('prompts/list'); + expect(userCache.captureGeneration('prompts/list')).toBe(1); + userCache.resetForReconnect(); + expect(userStore.get({ method: 'tools/list' })).toBeDefined(); + expect(userCache.captureGeneration('prompts/list')).toBe(0); + // Index dropped → re-derived from the (still-populated) store on next read. + expect((userCache as unknown as { _toolIndex?: unknown })._toolIndex).toBeUndefined(); + expect((await userCache.toolDefinition('a'))?.name).toBe('a'); + + // Default: store is cleared. + const defStore = new InMemoryResponseCacheStore(); + const defCache = new ClientResponseCache(defStore, false); + await defCache.write('tools/list', { tools: [TOOL_A] }, defCache.captureGeneration('tools/list')); + defCache.resetForReconnect(); + expect(defStore.get({ method: 'tools/list' })).toBeUndefined(); + expect(await defCache.toolDefinition('a')).toBeUndefined(); + }); + + it('write stores a defensive copy: caller-side mutation cannot reach the cache or its derived index', async () => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, false); + const value = { tools: [{ ...TOOL_A }, { ...TOOL_B }] }; + await cache.write('tools/list', value, cache.captureGeneration('tools/list')); + // Mutate the caller's reference (the same object _listAllPages returns). + value.tools.length = 0; + // The cached entry is a structuredClone, so the store and the + // stamp-memoized index are unaffected. + expect((store.get({ method: 'tools/list' })?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['a', 'b']); + expect((await cache.toolDefinition('a'))?.name).toBe('a'); + expect((await cache.toolDefinition('b'))?.name).toBe('b'); + }); + + it('a custom store whose set() rejects is routed to reportError and write still resolves', async () => { + const store: ResponseCacheStore = new InMemoryResponseCacheStore(); + store.set = () => Promise.reject(new Error('redis down')); + const reported: unknown[] = []; + const cache = new ClientResponseCache(store, true, e => reported.push(e)); + // The write resolves (cache bookkeeping never costs the caller a fetched + // result) and the failure is reported via the sink. + await expect(cache.write('tools/list', { tools: [TOOL_A] }, cache.captureGeneration('tools/list'))).resolves.toBeUndefined(); + expect(reported).toHaveLength(1); + expect((reported[0] as Error).message).toBe('redis down'); + }); + + it('toolDefinition: miss before any list, hit after, memoized index re-derives only on stamp change', async () => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, true); + expect(await cache.toolDefinition('a')).toBeUndefined(); + + store.set({ method: 'tools/list' }, { value: { tools: [TOOL_A, TOOL_B] } }); + const hit = await cache.toolDefinition('a'); + expect(hit?.name).toBe('a'); + // Same backing entry → identical reference (memoized index, not re-derived). + expect(await cache.toolDefinition('a')).toBe(hit); + + // A fresh write bumps the store stamp → the index re-derives (the new + // entry's tool instance is what comes back, not the memoized one). + store.set({ method: 'tools/list' }, { value: { tools: [{ ...TOOL_A }, { ...TOOL_B }] } }); + const hit2 = await cache.toolDefinition('a'); + expect(hit2?.name).toBe('a'); + expect(hit2).not.toBe(hit); + }); +}); + +interface Scripted { + clientTx: InMemoryTransport; + serverTx: InMemoryTransport; + listCount: () => number; + listParams: () => ({ cursor?: string; _meta?: unknown } | undefined)[]; +} + +async function scriptedModernServer(pages: Tool[][]): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + let lists = 0; + const params: ({ cursor?: string; _meta?: unknown } | undefined)[] = []; + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true }, prompts: {}, resources: {} }, + serverInfo: { name: 'scripted', version: '1.0.0' } + } + }); + } else if (r.method === 'tools/list') { + lists++; + params.push(r.params as { cursor?: string; _meta?: unknown } | undefined); + const cursor = (r.params as { cursor?: string } | undefined)?.cursor; + const idx = cursor === undefined ? 0 : Number(cursor); + const next = idx + 1 < pages.length ? String(idx + 1) : undefined; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: pages[idx] ?? [], + ...(next !== undefined && { nextCursor: next }) + } + }); + } else if (r.method === 'prompts/list' || r.method === 'resources/list' || r.method === 'resources/templates/list') { + const key = r.method === 'prompts/list' ? 'prompts' : r.method === 'resources/list' ? 'resources' : 'resourceTemplates'; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { resultType: 'complete', ttlMs: 0, cacheScope: 'private', [key]: [] } + }); + } + }; + await serverTx.start(); + return { clientTx, serverTx, listCount: () => lists, listParams: () => params }; +} + +function modernClient(store?: InMemoryResponseCacheStore): Client { + return new Client( + { name: 'cache-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, ...(store && { responseCacheStore: store }) } + ); +} + +/** Reach the private `_cache` collaborator for testing the derived view through the Client wiring. */ +const cacheOf = (client: Client): ClientResponseCache => (client as unknown as { _cache: ClientResponseCache })._cache; +const toolDef = (client: Client, name: string): Promise => cacheOf(client).toolDefinition(name); + +describe('Client response-cache substrate', () => { + it('listTools() with no cursor reads every page, writes one cache entry; listTools({cursor}) stays per-page and does not write', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A], [TOOL_B]]); + const client = modernClient(store); + await client.connect(clientTx); + + // Explicit cursor → one page, NO cache write (partial pages never go in). + const page = await client.listTools({ cursor: '1' }); + expect(page.tools.map(t => t.name)).toEqual(['b']); + expect(page.nextCursor).toBeUndefined(); + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + expect(listCount()).toBe(1); + + // No cursor → aggregates every page and writes one entry. + const { tools, nextCursor } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['a', 'b']); + expect(nextCursor).toBeUndefined(); + expect(listCount()).toBe(3); + + const entry = store.get({ method: 'tools/list' }); + expect((entry?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['a', 'b']); + }); + + it('the auto-aggregate path threads caller params (e.g. _meta trace context) into every page request', async () => { + const { clientTx, listParams } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A]]); + const client = modernClient(); + await client.connect(clientTx); + + const traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + const { tools } = await client.listTools({ _meta: { traceparent } }); + expect(tools.map(t => t.name)).toEqual(['a', 'b', 'a']); + // _listAllPages threads {...baseParams} on page 1 and {...baseParams, cursor} + // on every follow-up page, so the caller's _meta reaches every wire + // request the walk issues. + expect(listParams()).toHaveLength(3); + for (const p of listParams()) { + // The Protocol layer may auto-attach the modern-era envelope into + // _meta; assert the caller's key is present rather than exact-match. + expect((p?._meta as { traceparent?: string } | undefined)?.traceparent).toBe(traceparent); + } + expect(listParams().map(p => p?.cursor)).toEqual([undefined, '1', '2']); + }); + + it('mutating the returned aggregate does not corrupt the cache or its derived index', async () => { + const { clientTx } = await scriptedModernServer([[TOOL_A], [TOOL_B]]); + const client = modernClient(); + await client.connect(clientTx); + + const result = await client.listTools(); + expect((await toolDef(client, 'a'))?.name).toBe('a'); + // Common previously-harmless caller patterns. + result.tools.sort((x, y) => y.name.localeCompare(x.name)); + result.tools.length = 0; + // ClientResponseCache.write stored a structuredClone, so neither the + // backing entry nor the stamp-memoized name → Tool index moved. + expect((await toolDef(client, 'a'))?.name).toBe('a'); + expect((await toolDef(client, 'b'))?.name).toBe('b'); + }); + + it('the auto-aggregate path throws SdkError(ListPaginationExceeded) when listMaxPages is hit and does not write a partial entry', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A]]); + const client = new Client( + { name: 'cache-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, responseCacheStore: store, listMaxPages: 2 } + ); + await client.connect(clientTx); + + const error = await client.listTools().catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.ListPaginationExceeded); + expect((error as SdkError).message).toMatch(/exceeded listMaxPages \(2\); server pagination did not terminate/); + expect((error as SdkError).data).toEqual({ method: 'tools/list', listMaxPages: 2 }); + // Aggregate-then-write: the throw happens before the cache write, so nothing is cached. + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + // The per-page path is never capped. + const page = await client.listTools({ cursor: '2' }); + expect(page.tools.map(t => t.name)).toEqual(['a']); + }); + + it('listPrompts/listResources/listResourceTemplates auto-aggregate and write the response cache', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + + await client.listPrompts(); + await client.listResources(); + await client.listResourceTemplates(); + expect(store.get({ method: 'prompts/list' })).toBeDefined(); + expect(store.get({ method: 'resources/list' })).toBeDefined(); + expect(store.get({ method: 'resources/templates/list' })).toBeDefined(); + }); + + it('toolDefinition through the Client wiring: miss before any list, hit after', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A, TOOL_B]]); + const client = modernClient(store); + await client.connect(clientTx); + + expect(await toolDef(client, 'a')).toBeUndefined(); + await client.listTools(); + expect((await toolDef(client, 'a'))?.name).toBe('a'); + expect((await toolDef(client, 'b'))?.name).toBe('b'); + }); + + it('notifications/tools/list_changed evicts the tools/list entry (no refetch)', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx, listCount } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + await client.listTools(); + expect(store.get({ method: 'tools/list' })).toBeDefined(); + expect(await toolDef(client, 'a')).toBeDefined(); + + const before = listCount(); + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + // Evicted, not refetched. + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + expect(await toolDef(client, 'a')).toBeUndefined(); + expect(listCount()).toBe(before); + }); + + it('notifications/resources/list_changed evicts both resources list verbs', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + await client.listResources(); + await client.listResourceTemplates(); + expect(store.get({ method: 'resources/list' })).toBeDefined(); + expect(store.get({ method: 'resources/templates/list' })).toBeDefined(); + + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/resources/list_changed' } as JSONRPCMessage); + expect(store.get({ method: 'resources/list' })).toBeUndefined(); + expect(store.get({ method: 'resources/templates/list' })).toBeUndefined(); + }); + + it('_resetConnectionState leaves a user-supplied store untouched and drops the derived index', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + await client.listTools(); + expect(store.get({ method: 'tools/list' })).toBeDefined(); + + await client.close(); + // A user-supplied store is NOT cleared on close/reconnect (defeats the + // only reason to supply one); the per-instance default IS cleared. + expect(store.get({ method: 'tools/list' })).toBeDefined(); + // The derived index is connection-scoped regardless: it is dropped, and + // the next read re-derives from the (still-populated) store. + expect((cacheOf(client) as unknown as { _toolIndex?: unknown })._toolIndex).toBeUndefined(); + }); + + it('a notification whose method is an Object.prototype name does not abort dispatch', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + let fallback: string | undefined; + client.fallbackNotificationHandler = async n => { + fallback = n.method; + }; + let errored = false; + client.onerror = () => { + errored = true; + }; + await client.connect(clientTx); + + await serverTx.send({ jsonrpc: '2.0', method: 'constructor' } as JSONRPCMessage); + // The `Object.hasOwn` guard means `constructor` (an inherited prototype + // member) is NOT looked up as an eviction list and dispatch reaches the + // fallback handler without an error. + expect(errored).toBe(false); + expect(fallback).toBe('constructor'); + }); + + it('a custom store whose set() rejects is routed to onerror and the aggregate still returns', async () => { + const store = new InMemoryResponseCacheStore(); + (store as ResponseCacheStore).set = () => Promise.reject(new Error('redis down')); + const { clientTx } = await scriptedModernServer([[TOOL_A], [TOOL_B]]); + const client = modernClient(store); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await client.connect(clientTx); + + // Cache bookkeeping never costs the caller a result it already fetched + // (consistent with the eviction path): the store failure is reported + // via onerror and the fully-fetched aggregate still comes back. + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['a', 'b']); + expect(errors.map(e => e.message)).toContain('redis down'); + }); + + it('a custom store whose evict() throws is routed to onerror and dispatch still runs', async () => { + const store = new InMemoryResponseCacheStore(); + store.evict = () => { + throw new Error('boom'); + }; + const { clientTx, serverTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + let dispatched = false; + client.setNotificationHandler('notifications/tools/list_changed', async () => { + dispatched = true; + }); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await client.connect(clientTx); + + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + expect(errors.map(e => e.message)).toContain('boom'); + expect(dispatched).toBe(true); + }); +}); diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index ac9435102e..b808d98877 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -43,6 +43,15 @@ export enum SdkErrorCode { * the flow manually. */ InputRequiredRoundsExceeded = 'INPUT_REQUIRED_ROUNDS_EXCEEDED', + /** + * The auto-aggregating no-`cursor` `listTools()` / `listPrompts()` / + * `listResources()` / `listResourceTemplates()` walk hit the + * `ClientOptions.listMaxPages` cap without the server's pagination + * converging. `data.method` carries the list verb and + * `data.listMaxPages` the cap that was hit; raise the cap or fall back to + * explicit per-page `{ cursor }` calls. + */ + ListPaginationExceeded = 'LIST_PAGINATION_EXCEEDED', /** * The spec method being sent does not exist on the negotiated protocol * version's wire era (e.g. `tasks/get` toward a 2026-07-28 peer, or diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts index bfd6730385..afef72e2ee 100644 --- a/packages/core/test/types/errorSurfacePins.test.ts +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -76,6 +76,7 @@ describe('SdkErrorCode', () => { InvalidResult: 'INVALID_RESULT', UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', InputRequiredRoundsExceeded: 'INPUT_REQUIRED_ROUNDS_EXCEEDED', + ListPaginationExceeded: 'LIST_PAGINATION_EXCEEDED', MethodNotSupportedByProtocolVersion: 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', EraNegotiationFailed: 'ERA_NEGOTIATION_FAILED', ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 5058dcdd5d..6fff6c79d6 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -441,13 +441,7 @@ export const REQUIREMENTS: Record = { 'tools:list:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#listing-tools', behavior: - 'tools/list supports cursor pagination: the nextCursor returned by a list handler round-trips back to the handler as an opaque cursor until the listing is exhausted.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + 'tools/list supports cursor pagination: the nextCursor returned by a list handler round-trips back to the handler as an opaque cursor until the listing is exhausted.' }, 'tools:call:concurrent': { source: 'sdk', @@ -571,13 +565,7 @@ export const REQUIREMENTS: Record = { }, 'resources:list:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#listing-resources', - behavior: 'resources/list supports cursor pagination.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + behavior: 'resources/list supports cursor pagination.' }, 'resources:read:blob': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#reading-resources', @@ -612,13 +600,7 @@ export const REQUIREMENTS: Record = { }, 'resources:templates:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination#operations-supporting-pagination', - behavior: 'resources/templates/list supports cursor pagination.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + behavior: 'resources/templates/list supports cursor pagination.' }, 'resources:unsubscribe:stops-updates': { transports: STATEFUL_TRANSPORTS, @@ -711,13 +693,7 @@ export const REQUIREMENTS: Record = { }, 'prompts:list:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#listing-prompts', - behavior: 'prompts/list supports cursor pagination.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + behavior: 'prompts/list supports cursor pagination.' }, 'prompts:get:multi-message': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#getting-a-prompt', diff --git a/test/e2e/scenarios/pagination.test.ts b/test/e2e/scenarios/pagination.test.ts index e0106494d6..93c522431a 100644 --- a/test/e2e/scenarios/pagination.test.ts +++ b/test/e2e/scenarios/pagination.test.ts @@ -95,19 +95,20 @@ verifies('pagination:client:cursor-handling', async ({ transport }: TestArgs) => await using _ = await wire(transport, makeServer, client); const tap = tapWire(client); - const collectedPages: string[][] = []; - let result = await client.listTools(); - collectedPages.push(result.tools.map(t => t.name)); - while (result.nextCursor !== undefined) { - // A run-away loop means the test fixture, not the SDK, is broken — fail fast instead of hitting the suite timeout. - if (collectedPages.length >= pages.size) throw new Error('nextCursor still present after the last page'); - result = await client.listTools({ cursor: result.nextCursor }); - collectedPages.push(result.tools.map(t => t.name)); - } + // No-arg listTools() auto-aggregates; the client walks the cursor chain on the wire. + const result = await client.listTools(); + expect(result.nextCursor).toBeUndefined(); + expect(result.tools.map(t => t.name)).toEqual([ + 'get_weather', + 'get_forecast', + 'get_alerts', + 'convert_units', + 'list_stations', + 'get_station' + ]); - // The handler got back exactly the cursors it issued, and every page arrived once, in order. + // The handler got back exactly the cursors it issued, once each, in order. expect(receivedCursors).toEqual([undefined, cursorToPage2, cursorToPage3]); - expect(collectedPages).toEqual([['get_weather', 'get_forecast', 'get_alerts'], ['convert_units'], ['list_stations', 'get_station']]); // The wire requests carried the server-issued strings byte-for-byte — opaque, unparsed, unmodified. const wireListRequests = tap.sent.filter(m => isJSONRPCRequest(m)).filter(m => m.method === 'tools/list'); diff --git a/test/e2e/scenarios/prompts.test.ts b/test/e2e/scenarios/prompts.test.ts index b5052b5572..fa941cafe0 100644 --- a/test/e2e/scenarios/prompts.test.ts +++ b/test/e2e/scenarios/prompts.test.ts @@ -128,21 +128,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listPrompts(); - expect(first.prompts.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.prompts.map(p => p.name)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listPrompts({ cursor: result.nextCursor }); - for (const p of result.prompts) seen.add(p.name); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listPrompts() auto-aggregates every page. + const all = await client.listPrompts(); + expect(all.prompts.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.prompts.map(p => p.name)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -174,29 +164,20 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - const cursorsSent: string[] = []; - let pages = 0; - let result = await client.listPrompts(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const p of result.prompts) { - expect(seen.has(p.name)).toBe(false); - seen.add(p.name); - } - pages++; - if (result.nextCursor === undefined) break; - cursorsSent.push(result.nextCursor); - result = await client.listPrompts({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - - expect(pages).toBe(3); + // No-arg listPrompts() auto-aggregates every page; the server receives + // the cursor walk verbatim (protocol-level pagination is what is + // verified here). + const result = await client.listPrompts(); + expect(result.nextCursor).toBeUndefined(); + const seen = new Set(result.prompts.map(p => p.name)); expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); - expect(cursorsReceived).toEqual([undefined, '10', '20']); - expect(cursorsSent).toEqual(['10', '20']); + + // Explicit cursor → one raw page (per-page path). + const page = await client.listPrompts({ cursor: '10' }); + expect(page.prompts.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); diff --git a/test/e2e/scenarios/resources.test.ts b/test/e2e/scenarios/resources.test.ts index ea83696915..50341df406 100644 --- a/test/e2e/scenarios/resources.test.ts +++ b/test/e2e/scenarios/resources.test.ts @@ -78,21 +78,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listResources(); - expect(first.resources.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.resources.map(r => r.uri)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listResources({ cursor: result.nextCursor }); - for (const r of result.resources) seen.add(r.uri); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listResources() auto-aggregates every page. + const all = await client.listResources(); + expect(all.resources.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.resources.map(r => r.uri)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -122,30 +112,20 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - const cursorsSent: string[] = []; - let pages = 0; - let result = await client.listResources(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const r of result.resources) { - expect(seen.has(r.uri)).toBe(false); - seen.add(r.uri); - } - pages++; - if (result.nextCursor === undefined) break; - cursorsSent.push(result.nextCursor); - result = await client.listResources({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - + // No-arg listResources() auto-aggregates every page; the server + // receives the cursor walk verbatim (protocol-level pagination is + // what is verified here). + const result = await client.listResources(); expect(result.nextCursor).toBeUndefined(); - expect(pages).toBe(3); + const seen = new Set(result.resources.map(r => r.uri)); expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); - expect(cursorsReceived).toEqual([undefined, '10', '20']); - expect(cursorsSent).toEqual(['10', '20']); + + // Explicit cursor → one raw page (per-page path). + const page = await client.listResources({ cursor: '10' }); + expect(page.resources.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); @@ -498,21 +478,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listResourceTemplates(); - expect(first.resourceTemplates.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.resourceTemplates.map(t => t.uriTemplate)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listResourceTemplates({ cursor: result.nextCursor }); - for (const t of result.resourceTemplates) seen.add(t.uriTemplate); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listResourceTemplates() auto-aggregates every page. + const all = await client.listResourceTemplates(); + expect(all.resourceTemplates.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.resourceTemplates.map(t => t.uriTemplate)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -539,25 +509,17 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - let pages = 0; - let result = await client.listResourceTemplates(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const t of result.resourceTemplates) { - expect(seen.has(t.uriTemplate)).toBe(false); - seen.add(t.uriTemplate); - } - pages++; - if (result.nextCursor === undefined) break; - result = await client.listResourceTemplates({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - + // No-arg listResourceTemplates() auto-aggregates every page. + const result = await client.listResourceTemplates(); expect(result.nextCursor).toBeUndefined(); - expect(pages).toBe(3); + const seen = new Set(result.resourceTemplates.map(t => t.uriTemplate)); expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); + + // Explicit cursor → one raw page (per-page path). + const page = await client.listResourceTemplates({ cursor: '10' }); + expect(page.resourceTemplates.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); diff --git a/test/e2e/scenarios/tools.test.ts b/test/e2e/scenarios/tools.test.ts index 408712f23a..cd840535e2 100644 --- a/test/e2e/scenarios/tools.test.ts +++ b/test/e2e/scenarios/tools.test.ts @@ -758,21 +758,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listTools(); - expect(first.tools.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.tools.map(t => t.name)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listTools({ cursor: result.nextCursor }); - for (const t of result.tools) seen.add(t.name); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listTools() auto-aggregates every page. + const all = await client.listTools(); + expect(all.tools.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.tools.map(t => t.name)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -803,30 +793,20 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - const cursorsSent: string[] = []; - let pages = 0; - let result = await client.listTools(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const t of result.tools) { - expect(seen.has(t.name)).toBe(false); - seen.add(t.name); - } - pages++; - if (result.nextCursor === undefined) break; - cursorsSent.push(result.nextCursor); - result = await client.listTools({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - - expect(pages).toBeGreaterThan(1); + // No-arg listTools() auto-aggregates every page; the server receives + // the cursor walk verbatim (protocol-level pagination is what is + // verified here). + const result = await client.listTools(); + expect(result.nextCursor).toBeUndefined(); + const seen = new Set(result.tools.map(t => t.name)); + expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); + expect(cursorsReceived).toEqual([undefined, '10', '20']); - // SDK plumbing: the server's request handler saw the cursors verbatim. - expect(cursorsReceived).toHaveLength(pages); - expect(cursorsReceived[0]).toBeUndefined(); - expect(cursorsReceived.slice(1)).toEqual(cursorsSent); + // Explicit cursor → one raw page (per-page path). + const page = await client.listTools({ cursor: '10' }); + expect(page.tools.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); diff --git a/test/e2e/scenarios/validation.test.ts b/test/e2e/scenarios/validation.test.ts index 21121240e7..c1470acd3e 100644 --- a/test/e2e/scenarios/validation.test.ts +++ b/test/e2e/scenarios/validation.test.ts @@ -149,14 +149,18 @@ verifies('validation:pluggable-provider', async ({ transport }: TestArgs) => { await client.listTools(); - // The custom provider compiled the advertised outputSchema (once per tool - // that declares one — both forecast tools share the same schema). - expect(recorder.compiledSchemas).toEqual([FORECAST_OUTPUT_SCHEMA, FORECAST_OUTPUT_SCHEMA]); + // Derived-view behavior: the validator index is compiled lazily on the + // first callTool against the cached tools/list entry's stamp, not eagerly + // at listTools time. + expect(recorder.compiledSchemas).toEqual([]); // The custom provider's validator is the one consulted on tools/call, and - // its (delegated) verdict is what the caller sees. + // its (delegated) verdict is what the caller sees. The first call + // re-derives the whole name → validator index (once per tool that + // declares an outputSchema — both forecast tools share the same schema). const result = await client.callTool({ name: 'forecast', arguments: {} }); expect(result.structuredContent).toEqual({ celsius: 21, summary: 'mild and sunny' }); + expect(recorder.compiledSchemas).toEqual([FORECAST_OUTPUT_SCHEMA, FORECAST_OUTPUT_SCHEMA]); expect(recorder.validatedValues).toEqual([{ celsius: 21, summary: 'mild and sunny' }]); await expect(client.callTool({ name: 'forecast-corrupted', arguments: {} })).rejects.toBeInstanceOf(ProtocolError); From ef99992835fbc527f7066c49f450b7f1947206b6 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:21:21 +0100 Subject: [PATCH 36/37] =?UTF-8?q?feat:=20SEP-2243=20custom=20half=20?= =?UTF-8?q?=E2=80=94=20Mcp-Param=20header=20codec,=20client=20mirroring,?= =?UTF-8?q?=20server=20validation=20(#2327)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/sep-2243-mcp-param-client.md | 10 + .changeset/sep-2243-mcp-param-server.md | 5 + docs/client.md | 10 + docs/migration-SKILL.md | 12 +- docs/migration.md | 15 + docs/server.md | 6 +- packages/client/src/client/client.ts | 213 +++++++-- packages/client/src/client/responseCache.ts | 6 +- packages/client/src/client/streamableHttp.ts | 100 +++++ packages/client/src/index.ts | 2 +- .../test/client/mcpParamMirroring.test.ts | 420 ++++++++++++++++++ packages/core/src/index.ts | 1 + .../core/src/shared/inboundClassification.ts | 18 +- .../core/src/shared/inputRequiredEngine.ts | 5 + packages/core/src/shared/mcpParamHeaders.ts | 412 +++++++++++++++++ packages/core/src/shared/protocol.ts | 4 +- packages/core/src/shared/transport.ts | 13 + .../test/shared/inputRequiredEngine.test.ts | 6 + .../core/test/shared/mcpParamHeaders.test.ts | 330 ++++++++++++++ .../server/src/server/createMcpHandler.ts | 31 +- packages/server/src/server/mcp.ts | 84 +++- .../test/server/mcpParamValidation.test.ts | 136 ++++++ .../expected-failures.2026-07-28.yaml | 3 - test/conformance/expected-failures.yaml | 3 - test/conformance/src/everythingClient.ts | 63 +++ test/conformance/src/everythingServer.ts | 22 + test/e2e/helpers/index.ts | 3 + test/e2e/requirements.ts | 9 + test/e2e/scenarios/sep2243.test.ts | 60 +++ 29 files changed, 1956 insertions(+), 46 deletions(-) create mode 100644 .changeset/sep-2243-mcp-param-client.md create mode 100644 .changeset/sep-2243-mcp-param-server.md create mode 100644 packages/client/test/client/mcpParamMirroring.test.ts create mode 100644 packages/core/src/shared/mcpParamHeaders.ts create mode 100644 packages/core/test/shared/mcpParamHeaders.test.ts create mode 100644 packages/server/test/server/mcpParamValidation.test.ts create mode 100644 test/e2e/scenarios/sep2243.test.ts diff --git a/.changeset/sep-2243-mcp-param-client.md b/.changeset/sep-2243-mcp-param-client.md new file mode 100644 index 0000000000..bdefc019a4 --- /dev/null +++ b/.changeset/sep-2243-mcp-param-client.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +SEP-2243 `Mcp-Param-*` client-side mirroring (protocol revision 2026-07-28). On a 2026-07-28 connection over Streamable HTTP, `Client.callTool()` now mirrors tool arguments designated with `x-mcp-header` in the tool's `inputSchema` into `Mcp-Param-{Name}` HTTP headers (with the spec's `=?base64?…?=` sentinel encoding for values that are not safe plain-ASCII field values), and on a non-stdio modern connection `Client.listTools()` (and the client's internal `tools/list` cache) exclude tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning naming the tool and the reason. The legacy-era `callTool` and `listTools` paths are unchanged at the wire level. Browser environments skip mirroring (dynamically named headers cannot be statically allow-listed for credentialed CORS); a conforming SEP-2243 server will reject a `tools/call` whose body carries a non-null value for an `x-mcp-header` parameter when the matching header is absent, so calling such a tool with that argument from a browser is a known limitation. New `CallToolRequestOptions.toolDefinition` lets callers supply the tool definition directly so mirroring and output-schema validation can run without a prior `tools/list`. `TransportSendOptions.headers` is added (additive, optional) for per-request HTTP headers; the Streamable HTTP transport skips reserved standard/auth header names (`authorization`, `mcp-protocol-version`, `mcp-method`, `mcp-name`, `mcp-session-id`, `content-type`); transports that share a single channel (stdio, in-memory) ignore it. + +The Streamable HTTP transport now emits the `Mcp-Name` standard header on every modern-enveloped request (`params.name` for `tools/call`/`prompts/get`, `params.uri` for `resources/read`), sentinel-encoded. + +**Behavior change (modern era only):** on a modern-enveloped request the Streamable HTTP transport now surfaces an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the pending request id in-band as a `ProtocolError` (instead of `SdkHttpError`), so the `HEADER_MISMATCH` recovery retry can fire. Legacy-era exchanges are unchanged. diff --git a/.changeset/sep-2243-mcp-param-server.md b/.changeset/sep-2243-mcp-param-server.md new file mode 100644 index 0000000000..796b42ec53 --- /dev/null +++ b/.changeset/sep-2243-mcp-param-server.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +SEP-2243 `Mcp-Param-*` server-side validation (protocol revision 2026-07-28). On the modern (2026-07-28) serving path, `createMcpHandler` now validates `Mcp-Param-{Name}` headers against the named tool's `x-mcp-header` declarations and the body `arguments` before dispatch: a missing header for a present body value, a header that decodes to a different value than the body, or an invalid `=?base64?…?=` sentinel is rejected with `400 Bad Request` and JSON-RPC `-32001` (`HeaderMismatch`) — the same shape the existing standard-header cross-checks emit. A `null`/absent body value passes regardless of any header (the spec's "server MUST NOT expect the header" rows). `McpServer.registerTool` now warns at registration time when an `x-mcp-header` declaration violates the spec's constraints. The 2025-era serving paths and the low-level `Server` factory shape are unchanged. diff --git a/docs/client.md b/docs/client.md index 79ddd12157..36518fae75 100644 --- a/docs/client.md +++ b/docs/client.md @@ -301,6 +301,16 @@ const result = await client.callTool( console.log(result.content); ``` +### `x-mcp-header` parameter mirroring (2026-07-28 draft) + +On a 2026-07-28 connection over Streamable HTTP, `callTool()` mirrors any argument whose `inputSchema` property carries an `x-mcp-header` annotation into an `Mcp-Param-{Name}` HTTP request header so intermediaries can route on it without parsing the body. The mirrored headers +are built from the client's internal `tools/list` cache; if you already hold the tool definition (e.g. from configuration), pass it via `CallToolRequestOptions.toolDefinition` so mirroring runs without a prior list. On a cache miss the call is sent without `Mcp-Param-*` headers +and, when a conforming server rejects it with `-32001` (`HeaderMismatch`), `callTool()` refreshes the definition cache once and retries. + +On a non-stdio modern connection `listTools()` (and the internal `tools/list` cache) exclude tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning that names the tool and the reason. Browser clients skip mirroring (dynamically named +headers cannot be statically allow-listed for credentialed CORS), so calling an `x-mcp-header` tool with a non-null designated argument from a browser against a server that enforces SEP-2243 validation will be rejected — a known limitation. The legacy-era `callTool`/`listTools` +paths are unchanged. + ## Resources Resources are read-only data — files, database schemas, configuration — that your application can retrieve from a server and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 8b14aa8cc1..ee3200c524 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -116,7 +116,7 @@ Three error classes now exist: | Capability not supported | `new Error(...)` | `SdkError` with `SdkErrorCode.CapabilityNotSupported` | | Not connected | `new Error('Not connected')` | `SdkError` with `SdkErrorCode.NotConnected` | | Invalid params (server response) | `McpError` with `ErrorCode.InvalidParams` | `ProtocolError` with `ProtocolErrorCode.InvalidParams` | -| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` | +| HTTP transport error (legacy era) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` | | Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` | | 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` | | 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` | @@ -124,6 +124,9 @@ Three error classes now exist: | Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` | | Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` | +**Modern-era exception** to the `SdkHttpError` rows above: on a modern-enveloped (2026-07-28) Streamable HTTP request, an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the pending request id is delivered in-band as a `ProtocolError` (e.g. `-32001` +HeaderMismatch from a SEP-2243 `Mcp-Param-*` rejection), not as `SdkHttpError`. Legacy-era exchanges and generic HTTP failures are unchanged. + New `SdkErrorCode` enum values: - `SdkErrorCode.NotConnected` = `'NOT_CONNECTED'` @@ -174,6 +177,13 @@ if (error instanceof SdkHttpError) { break; } } +// Modern-era (2026-07-28) only: a 400 carrying a JSON-RPC error body addressed +// to the pending request id surfaces as ProtocolError, NOT SdkHttpError — e.g. +// a SEP-2243 -32001 HeaderMismatch from createMcpHandler. Legacy-era 400s and +// generic HTTP failures still map to SdkHttpError above. +if (error instanceof ProtocolError) { + console.log('In-band JSON-RPC error:', error.code); +} ``` ### OAuth error consolidation diff --git a/docs/migration.md b/docs/migration.md index 7e74ad8843..5c5c63a8bb 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1125,6 +1125,21 @@ The entry performs no Origin/Host validation (see the origin-validation middlewa headers. Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around (`const { fetch } = handler`). +### `Mcp-Param-*` request-metadata headers (SEP-2243, 2026-07-28 draft) + +On a 2026-07-28 connection over Streamable HTTP, `Client.callTool()` mirrors tool arguments designated with `x-mcp-header` in the tool's `inputSchema` into `Mcp-Param-{Name}` HTTP request headers (Base64-sentinel-encoded where needed) so HTTP intermediaries can route on them +without parsing the body, and `createMcpHandler` rejects a `tools/call` whose `Mcp-Param-*` headers are missing for a present body value, malformed, or disagree with the body — `400 Bad Request` with JSON-RPC `-32001` (`HeaderMismatch`). The legacy-era serving paths and the +client's legacy-era `callTool`/`listTools` are unchanged at the wire level. + +The Streamable HTTP transport now also emits the `Mcp-Name` standard header on every modern-enveloped request (`tools/call`/`prompts/get` → `params.name`; `resources/read` → `params.uri`), sentinel-encoded the same way, so intermediaries can route on the resource name without +parsing the body. **On a modern-enveloped request only**, an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the pending request id is now delivered in-band as a `ProtocolError` (so the `HEADER_MISMATCH` recovery retry can fire); a legacy-era +exchange still surfaces `400` as the existing `SdkHttpError`, so `e instanceof SdkHttpError && e.status === 400` callers are unchanged. + +Two additive options support this: `CallToolRequestOptions.toolDefinition` (pass the tool definition directly so mirroring and output-schema validation run without a prior `tools/list`) and `TransportSendOptions.headers` (per-request HTTP headers; the Streamable HTTP transport +skips the reserved standard/auth header names so a per-request header cannot override `mcp-protocol-version`/`mcp-method`/`mcp-name`/`mcp-session-id`/`authorization`; transports that share a single channel — stdio, in-memory — ignore it). On a non-stdio modern connection, +`Client.listTools()` (and the client's internal `tools/list` cache) exclude tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning naming the tool and the reason. Browser clients skip mirroring (dynamically named headers cannot be +statically allow-listed for credentialed CORS); calling an `x-mcp-header` tool with a non-null designated argument from a browser against a conforming SEP-2243 server is therefore a known limitation. + ### Serving the 2026-07-28 draft revision on stdio: `serveStdio` The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to that diff --git a/docs/server.md b/docs/server.md index a66bffd079..e9e6ba4c42 100644 --- a/docs/server.md +++ b/docs/server.md @@ -100,7 +100,11 @@ const server = new McpServer( Tools let clients invoke actions on your server — they are usually the main way LLMs call into your application (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). -Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (Zod) to validate arguments, and optionally an `outputSchema` for structured return values: +Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (Zod) to validate arguments, and optionally an `outputSchema` for structured return values. + +> On the 2026-07-28 draft serving path, a tool whose `inputSchema` carries an `x-mcp-header` annotation has that argument mirrored into an `Mcp-Param-{Name}` HTTP request header by conforming clients. `createMcpHandler` validates those headers before dispatch and rejects a +> `tools/call` whose `Mcp-Param-*` headers are missing for a present body value, malformed, or disagree with the body — `400 Bad Request` with JSON-RPC `-32001` (`HeaderMismatch`). `registerTool` warns at registration time when an `x-mcp-header` declaration violates the +> spec's constraints. The 2025-era serving paths and the low-level `Server` factory shape are unchanged. ```ts source="../examples/guides/serverGuide.examples.ts#registerTool_basic" server.registerTool( diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index a43aee20a3..07071e8e34 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -49,9 +49,11 @@ import type { SubscriptionFilter, Tool, Transport, - UnsubscribeRequest + UnsubscribeRequest, + XMcpHeaderScanResult } from '@modelcontextprotocol/core'; import { + buildMcpParamHeaders, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, codecForVersion, @@ -59,6 +61,7 @@ import { CreateMessageResultWithToolsSchema, DEFAULT_REQUEST_TIMEOUT_MSEC, DiscoverResultSchema, + HEADER_MISMATCH_ERROR_CODE, isJSONRPCErrorResponse, isJSONRPCRequest, isModernProtocolVersion, @@ -72,6 +75,7 @@ import { ProtocolErrorCode, resolveInputRequiredDriverConfig, runInputRequiredFlow, + scanXMcpHeaderDeclarations, SdkError, SdkErrorCode, SUBSCRIPTION_ID_META_KEY, @@ -288,6 +292,24 @@ export type ClientOptions = ProtocolOptions & { responseCacheStore?: ResponseCacheStore; }; +/** + * Options for {@linkcode Client.callTool}. Extends {@linkcode RequestOptions} + * with an escape hatch for callers that already hold the tool definition + * (e.g. from a previous session or configuration) — pass it via + * `toolDefinition` so SEP-2243 `Mcp-Param-*` header mirroring can run without a + * prior `tools/list`. + */ +export type CallToolRequestOptions = RequestOptions & { + /** + * The tool definition to use for SEP-2243 `Mcp-Param-*` header mirroring on + * a 2026-07-28 connection over Streamable HTTP, AND for output-schema + * validation of the result. When set, the client uses this definition's + * `inputSchema` and `outputSchema` instead of (and without consulting) the + * cached `tools/list` result, so the two derived views agree. + */ + toolDefinition?: Tool; +}; + /** * `list_changed` notification → response-cache method(s) to evict. `resources` * covers both list verbs (the spec's "relevant notification ⇒ immediately @@ -1471,12 +1493,11 @@ export class Client extends Protocol { /** * Compile a single tool's `outputSchema` (or `undefined` when absent / - * uncompilable). Passed as the compile callback to - * {@linkcode ClientResponseCache.outputValidator} so the cache class stays - * free of any validator-provider dependency. One tool's uncompilable - * `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`) - * must not poison every other tool's `callTool` — warn naming the - * offender and return `undefined` so the validator index simply omits it. + * uncompilable) — the caller-supplied-definition path of + * {@linkcode callTool} so an explicit `options.toolDefinition` is the + * source for BOTH mirroring AND output validation. Also passed as the + * compile callback to {@linkcode ClientResponseCache.outputValidator} so + * the cache class stays free of any validator-provider dependency. */ private _compileOutputValidator(tool: Tool): JsonSchemaValidator | undefined { if (!tool.outputSchema) return undefined; @@ -1490,6 +1511,24 @@ export class Client extends Protocol { } } + /** + * Resolve the SEP-2243 `x-mcp-header` declaration scan for a tool name. + * + * The caller-supplied `toolDefinition` escape hatch wins; otherwise the + * cached `tools/list` entry (via the cache's `toolDefinition`) is the + * source. Freshness is the response cache's lifecycle: `list_changed` + * evicts, otherwise the held schema is the best information available + * regardless of age, and a stale schema is recovered through the + * `HEADER_MISMATCH` → evict-refetch-retry path in {@linkcode callTool}. + * On a miss the call proceeds without `Mcp-Param-*` headers (the spec's + * "client SHOULD send without custom headers" guidance) and relies on the + * same recovery. + */ + private async _resolveXMcpHeaderScan(name: string, override: Tool | undefined): Promise { + const tool = override ?? (await this._cache.toolDefinition(name)); + return tool === undefined ? undefined : scanXMcpHeaderDeclarations(tool.inputSchema); + } + /** Reads the contents of a resource by URI. */ async readResource(params: ReadResourceRequest['params'], options?: RequestOptions): Promise { return this.request({ method: 'resources/read', params }, options); @@ -1861,29 +1900,106 @@ export class Client extends Protocol { * } * ``` */ - async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise { + async callTool(params: CallToolRequest['params'], options?: CallToolRequestOptions): Promise { + // SEP-2243 `Mcp-Param-*` mirroring (protocol revision 2026-07-28; the + // 5-step client algorithm, steps 3–5). Modern-era only — the legacy + // `callTool` path is byte-untouched. Transports that share a single + // channel (stdio, in-memory) ignore the per-request `headers` option, + // so the spec's stdio MAY-ignore exemption holds without an explicit + // branch. In a browser environment, dynamically named `Mcp-Param-*` + // headers cannot be statically allow-listed for credentialed CORS + // (`Access-Control-Allow-Headers` admits no wildcard with + // credentials), so mirroring is skipped. NOTE: a conforming SEP-2243 + // server (including this SDK's own `createMcpHandler`) rejects a + // `tools/call` whose body carries a non-null value for an + // `x-mcp-header`-declared parameter when the matching `Mcp-Param-*` + // header is absent — so a browser client of this SDK cannot + // successfully call such a tool with that argument present unless the + // server relaxes the missing-header check. This is a known limitation + // of running SEP-2243 from a browser; pass `null` for the designated + // argument or supply `options.toolDefinition` against a relaxed server. + const mirroringActive = this.getProtocolEra() === 'modern' && detectProbeEnvironment() !== 'browser'; + // Mirroring (and output-schema validation below) read the cached + // `tools/list` entry directly via `_cache.toolDefinition` / + // `_cache.outputValidator`. `callTool` never issues a `tools/list` + // itself — the cache is populated by the caller's own + // {@linkcode listTools | listTools()} (which now auto-aggregates) and + // by the `HEADER_MISMATCH` recovery path below. A cold cache means + // the call proceeds without `Mcp-Param-*` headers (the spec's + // "client SHOULD send without custom headers" guidance) and without + // output-schema validation (the v1.x opportunistic behaviour, kept so + // a legacy/stdio `callTool` still issues zero extra requests). + const buildSendOptions = async (): Promise => { + if (!mirroringActive) return options; + // A custom store's `get()` may reject (the documented store + // contract); route that to `onerror` and degrade to sending + // without `Mcp-Param-*` headers — same posture as a cold cache — + // rather than aborting the call before it reaches the wire. + let scan: XMcpHeaderScanResult | undefined; + try { + scan = await this._resolveXMcpHeaderScan(params.name, options?.toolDefinition); + } catch (error) { + this._reportStoreError(error); + } + if (!scan?.valid || scan.declarations.length === 0) return options; + const paramHeaders = buildMcpParamHeaders(scan.declarations, params.arguments); + return Object.keys(paramHeaders).length === 0 ? options : { ...options, headers: { ...options?.headers, ...paramHeaders } }; + }; + // The method-keyed request() path validates the era registry's plain // CallToolResult schema — with the result map aligned to the typed // map there is no wider union to narrow away (Q1-SD2 holds by // construction). - const result = await this.request({ method: 'tools/call', params }, options); - - // Check if the tool has an outputSchema. Reads the cached - // `tools/list` entry (via the response cache's stamp-memoized - // `outputValidator` index) — `callTool` never issues a `tools/list` - // itself; the cache is populated by the caller's own - // {@linkcode listTools | listTools()}. A cold cache means validation - // is skipped (the v1.x opportunistic behaviour, kept so a legacy/stdio - // `callTool` still issues zero extra requests). The cache read is - // guarded the same way as `evict()`/`set()`: a custom store whose - // `get()` rejects AFTER the server has already executed the call must - // not surface as a `callTool()` rejection (a caller that retries on + let result: CallToolResult; + try { + result = await this.request({ method: 'tools/call', params }, await buildSendOptions()); + } catch (error) { + // SEP-2243 one-refresh-on-miss: a `HEADER_MISMATCH` rejection on a + // modern connection means the server enforced an `Mcp-Param-*` + // header we did not (or could not) send — the cached `tools/list` + // entry is stale (or cold). Evict it, repopulate via + // {@linkcode listTools | listTools()} (which auto-aggregates and + // writes the cache), and retry once with the now-known schema. + // Never on the legacy era; never when the caller supplied + // `toolDefinition` (their schema is authoritative). + const isHeaderMismatch = error instanceof ProtocolError && error.code === HEADER_MISMATCH_ERROR_CODE; + if (!mirroringActive || !isHeaderMismatch || options?.toolDefinition !== undefined) { + throw error; + } + const refreshOptions = { signal: options?.signal, timeout: options?.timeout }; + // A custom store's `evict()` may throw or reject (the documented + // store contract); route that to `onerror` and proceed — the + // generation bump already happened, so the refetch overwrites the + // stale entry regardless. + await this._cache.evict('tools/list').catch(error_ => this._reportStoreError(error_)); + // The recovery refetch may itself fail (e.g. `listMaxPages`, a + // transient error that hits only the `tools/list` walk). Surface + // it via `onerror` so the real cause is observable, then proceed + // to the retry. NOTE: when the refetch fails the cache stays + // empty and the retry goes out without `Mcp-Param-*` headers, so + // a conforming server will likely answer a second + // `HEADER_MISMATCH` — the refetch failure is observable only + // through `onerror`. + await this.listTools(undefined, refreshOptions).catch(error_ => this._reportStoreError(error_)); + result = await this.request({ method: 'tools/call', params }, await buildSendOptions()); + } + + // Check if the tool has an outputSchema. When the caller supplied + // `toolDefinition`, that definition is the source for BOTH the + // `Mcp-Param-*` mirroring above AND the output validation here — the + // two derived views must agree. The cache read is guarded the same + // way as `evict()`/`set()` above: a custom store whose `get()` + // rejects AFTER the server has already executed the call must not + // surface as a `callTool()` rejection (a caller that retries on // failure would re-execute a possibly side-effecting tool). Route to // `onerror` and degrade to skipping validation — the same outcome as // a cold cache. - const validator = await this._cache - .outputValidator(params.name, tool => this._compileOutputValidator(tool)) - .catch(error => void this._reportStoreError(error)); + const validator = + options?.toolDefinition === undefined + ? await this._cache + .outputValidator(params.name, tool => this._compileOutputValidator(tool)) + .catch(error => void this._reportStoreError(error)) + : this._compileOutputValidator(options.toolDefinition); if (validator) { // If tool has outputSchema, it MUST return structuredContent (unless it's an error) if (!result.structuredContent && !result.isError) { @@ -1954,12 +2070,53 @@ export class Client extends Protocol { return { tools: [] }; } if (params?.cursor !== undefined) { - // Explicit-cursor per-page contract: return one page; do NOT touch - // the response cache (a single page is not the complete aggregate - // the derived `outputValidator` index keys against). - return await this.request({ method: 'tools/list', params }, options); + // Per-page: single request, never written to the response cache. + // SEP-2243: the spec's MUST has no carve-out for paginated reads, + // so the per-page result is filtered (on a non-stdio modern + // connection) before returning — the suppressed tool is never + // observable. + const page = await this.request({ method: 'tools/list', params }, options); + this._excludeInvalidXMcpHeaderTools(page); + return page; } - return this._listAllPages('tools/list', params, options, (acc, page) => acc.tools.push(...page.tools)); + // Auto-aggregate: SEP-2243 invalid-`x-mcp-header` exclusion runs on + // the complete aggregate via the `finalize` hook before the cache + // write, so the cached entry never holds an unmirrorable tool. + return this._listAllPages( + 'tools/list', + params, + options, + (acc, page) => acc.tools.push(...page.tools), + acc => this._excludeInvalidXMcpHeaderTools(acc) + ); + } + + /** + * SEP-2243 (protocol revision 2026-07-28): a Streamable HTTP client MUST + * exclude tool definitions whose `x-mcp-header` declarations violate the + * constraints, and SHOULD log a warning naming the tool and the reason. + * Applied to the CACHED aggregated `tools/list` result (so the entry + * mirroring reads never holds an unmirrorable tool) AND to every public + * per-page {@linkcode listTools | listTools()} return (the spec's MUST + * has no carve-out for paginated reads). The gate is era-only on + * non-stdio transports — `detectProbeTransportKind` cannot distinguish a + * real HTTP transport from in-memory/custom transports (it only + * positively recognizes stdio), and over-excluding on a non-HTTP modern + * connection is harmless: those transports never carry per-request + * headers, so an excluded tool would have been uncallable on a Streamable + * HTTP arm of the same server. Mutates `result.tools` in place. + */ + private _excludeInvalidXMcpHeaderTools(result: ListToolsResult): void { + if (this.getProtocolEra() !== 'modern' || !this.transport || detectProbeTransportKind(this.transport) === 'stdio') return; + const filtered = result.tools.filter(tool => { + const scan = scanXMcpHeaderDeclarations(tool.inputSchema); + if (!scan.valid) { + console.warn(`[mcp-sdk] excluding tool '${tool.name}' from tools/list: invalid x-mcp-header declaration — ${scan.reason}`); + return false; + } + return true; + }); + if (filtered.length !== result.tools.length) result.tools = filtered; } /** diff --git a/packages/client/src/client/responseCache.ts b/packages/client/src/client/responseCache.ts index faa3f54195..8fbe90ca49 100644 --- a/packages/client/src/client/responseCache.ts +++ b/packages/client/src/client/responseCache.ts @@ -251,10 +251,8 @@ export class ClientResponseCache { * Returns `undefined` only when no `tools/list` response is held at all, * or the held list does not contain `name`. * - * No production caller in the substrate commit — the stacked SEP-2243 PR - * wires `callTool()`'s `Mcp-Param-*` mirroring through it. - * {@linkcode outputValidator} is the substrate's own derived view over the - * same entry. + * Consumed by `callTool()`'s SEP-2243 `_resolveXMcpHeaderScan` (mirroring) + * and, via {@linkcode outputValidator}, its output-schema validation. */ async toolDefinition(name: string): Promise { const entry = await this._store.get({ method: 'tools/list' }); diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 277e84e0fd..440422c130 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -3,10 +3,12 @@ import type { ReadableWritablePair } from 'node:stream/web'; import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; import { createFetchWithInit, + encodeMcpParamValue, isInitializedNotification, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, + isModernProtocolVersion, JSONRPCMessageSchema, normalizeHeaders, PROTOCOL_VERSION_META_KEY, @@ -182,6 +184,23 @@ export type StreamableHTTPClientTransportOptions = { protocolVersion?: string; }; +/** + * Standard/auth header names the transport owns. The per-request + * `TransportSendOptions.headers` carrier MUST NOT be able to override these — + * they are derived from connection state (`authorization`, `mcp-session-id`) + * or from the message body itself (`mcp-protocol-version`, `mcp-method`, + * `mcp-name`), and a per-request override would let a caller produce a + * header/body disagreement the server's SEP-2243 cross-checks reject. + */ +const RESERVED_REQUEST_HEADER_NAMES: ReadonlySet = new Set([ + 'authorization', + 'content-type', + 'mcp-protocol-version', + 'mcp-method', + 'mcp-name', + 'mcp-session-id' +]); + /** * `AbortSignal.any` with a manual fallback. `AbortSignal.any` landed in * Node 20.3; this package's `engines` floor is `>=20`, so 20.0–20.2 must be @@ -306,6 +325,42 @@ export class StreamableHTTPClientTransport implements Transport { } headers.set('mcp-protocol-version', envelopeVersion); headers.set('mcp-method', message.method); + // SEP-2243 standard headers, step 2 of the 5-step client algorithm: + // Mcp-Name mirrors `params.name` (tools/call, prompts/get) or + // `params.uri` (resources/read). The value is run through the same + // `=?base64?…?=` sentinel encoding the `Mcp-Param-*` codec uses so a + // non-ASCII name/URI (or one with leading/trailing whitespace, + // control characters, or CR/LF) cannot make `Headers.set()` throw a + // TypeError or silently normalize to a value that differs from the + // body. The spec's value-encoding rules apply to `Mcp-Name`; this + // SDK's server does not yet cross-check `Mcp-Name` against the body + // (tracked in expected-failures.yaml) — when it does it will decode + // the sentinel before comparison. + const params = message.params as { name?: unknown; uri?: unknown } | undefined; + const nameHeader = + message.method === 'resources/read' + ? typeof params?.uri === 'string' + ? params.uri + : undefined + : typeof params?.name === 'string' + ? params.name + : undefined; + if (nameHeader !== undefined) { + headers.set('mcp-name', encodeMcpParamValue(nameHeader)); + } + } + + /** + * `true` when the outbound message is a single request carrying a + * modern-era protocol-version envelope claim — the same predicate that + * gates body-derived `mcp-method`/`mcp-name` emission. Used to confine the + * 400-body-as-ProtocolError delivery to modern-era exchanges only. + */ + private _isModernEnvelopedRequest(message: JSONRPCMessage | JSONRPCMessage[]): boolean { + if (Array.isArray(message) || !isJSONRPCRequest(message)) return false; + const meta = (message.params as { _meta?: Record } | undefined)?._meta; + const v = meta?.[PROTOCOL_VERSION_META_KEY]; + return typeof v === 'string' && isModernProtocolVersion(v); } private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { @@ -659,6 +714,7 @@ export class StreamableHTTPClientTransport implements Transport { onresumptiontoken?: (token: string) => void; requestSignal?: AbortSignal; onRequestStreamEnd?: () => void; + headers?: Readonly>; } ): Promise { return this._send(message, options, false); @@ -672,6 +728,7 @@ export class StreamableHTTPClientTransport implements Transport { onresumptiontoken?: (token: string) => void; requestSignal?: AbortSignal; onRequestStreamEnd?: () => void; + headers?: Readonly>; } | undefined, isAuthRetry: boolean @@ -689,6 +746,19 @@ export class StreamableHTTPClientTransport implements Transport { const headers = await this._commonHeaders(); this._applyBodyDerivedHeaders(headers, message); + // Per-request additional headers (the Client passes SEP-2243 + // `Mcp-Param-*` here on a 2026-07-28 connection). Reserved + // standard/auth header names are skipped so a caller cannot + // accidentally override the body-derived or connection-level + // headers — `Headers.set` overwrites, so the only way to keep the + // transport-owned values authoritative is to refuse to write over + // them here. + if (options?.headers !== undefined) { + for (const [name, value] of Object.entries(options.headers)) { + if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; + headers.set(name, value); + } + } headers.set('content-type', 'application/json'); const userAccept = headers.get('accept'); const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream']; @@ -790,6 +860,36 @@ export class StreamableHTTPClientTransport implements Transport { } } + // SEP-2243 (and the rest of the inbound validation ladder) + // emit ladder rejections as HTTP 400 carrying a JSON-RPC error + // response body. Surface those in-band so `Protocol._onresponse` + // converts them to a typed `ProtocolError` matched to the + // pending request id — instead of an opaque transport error. + // Any 400 whose body is not a well-formed JSON-RPC error + // response (or whose id does not match an outstanding request) + // still falls through to the generic `SdkHttpError`. + // + // Modern-era only: gated on the outbound message carrying a + // 2026-07-28 envelope claim (the same gate the body-derived + // `mcp-method`/`mcp-name` headers use), so a legacy-era + // exchange keeps surfacing 400 as `SdkHttpError` exactly as + // before — the changeset's "legacy-era paths are unchanged" + // claim stays true and existing + // `e instanceof SdkHttpError && e.status === 400` callers do + // not silently stop matching. + if (response.status === 400 && typeof text === 'string' && this._isModernEnvelopedRequest(message)) { + try { + const parsed = JSONRPCMessageSchema.parse(JSON.parse(text)); + const requests = (Array.isArray(message) ? message : [message]).filter(m => isJSONRPCRequest(m)); + if (isJSONRPCErrorResponse(parsed) && requests.some(r => r.id === parsed.id)) { + this.onmessage?.(parsed); + return; + } + } catch { + // not a JSON-RPC error body — fall through to the generic SdkHttpError below. + } + } + throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { status: response.status, statusText: response.statusText, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index de5814a8c2..694084598d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -52,7 +52,7 @@ export { PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from './client/authExtensions.js'; -export type { ClientOptions, McpSubscription } from './client/client.js'; +export type { CallToolRequestOptions, ClientOptions, McpSubscription } from './client/client.js'; export { Client } from './client/client.js'; export { getSupportedElicitationModes } from './client/client.js'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess.js'; diff --git a/packages/client/test/client/mcpParamMirroring.test.ts b/packages/client/test/client/mcpParamMirroring.test.ts new file mode 100644 index 0000000000..424e0d5289 --- /dev/null +++ b/packages/client/test/client/mcpParamMirroring.test.ts @@ -0,0 +1,420 @@ +/** + * SEP-2243 client-side `Mcp-Param-*` mirroring (protocol revision 2026-07-28). + * + * Covers: `tools/list` exclusion of constraint-violating definitions; per-call + * `Mcp-Param-*` header construction from the response-cache's `tools/list` + * entry and the `toolDefinition` escape hatch; era-parity (legacy `callTool` + * byte-untouched); stdio MAY-ignore (no headers on a single-channel + * transport); the one-evict-refetch-retry on `HEADER_MISMATCH`. + */ +import type { JSONRPCMessage, JSONRPCRequest, Tool, TransportSendOptions } from '@modelcontextprotocol/core'; +import { encodeMcpParamValue, HEADER_MISMATCH_ERROR_CODE, InMemoryTransport, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import { InMemoryResponseCacheStore, type ResponseCacheStore } from '../../src/client/responseCache.js'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; + +const MODERN = '2026-07-28'; + +const REGION_TOOL: Tool = { + name: 'route', + inputSchema: { + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' }, query: { type: 'string' } } + } +}; + +const INVALID_TOOL: Tool = { + name: 'broken', + inputSchema: { type: 'object', properties: { a: { type: 'object', 'x-mcp-header': 'Data' } } } +}; + +interface Scripted { + clientTx: InMemoryTransport; + serverTx: InMemoryTransport; + /** Headers passed via TransportSendOptions for each tools/call (undefined when none). */ + callHeaders: Array | undefined>; + listCount: () => number; +} + +async function scriptedModernServer(pages: Tool[][], rejectFirstCall = false): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const callHeaders: Array | undefined> = []; + let calls = 0; + let lists = 0; + + // Tap the client→server channel to observe TransportSendOptions.headers + // (InMemoryTransport ignores it; this is the seam under test). + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise => { + if ((m as JSONRPCRequest).method === 'tools/call') { + callHeaders.push(opts?.headers ? { ...opts.headers } : undefined); + } + return realSend(m, opts); + }; + + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'scripted', version: '1.0.0' } + } + }); + } else if (r.method === 'tools/list') { + lists++; + const cursor = (r.params as { cursor?: string } | undefined)?.cursor; + const idx = cursor === undefined ? 0 : Number(cursor); + const next = idx + 1 < pages.length ? String(idx + 1) : undefined; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + ttlMs: 60_000, + cacheScope: 'public', + tools: pages[idx] ?? [], + ...(next !== undefined && { nextCursor: next }) + } + }); + } else if (r.method === 'tools/call') { + calls++; + if (rejectFirstCall && calls === 1) { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + error: { code: HEADER_MISMATCH_ERROR_CODE, message: 'Bad Request: the request headers and body disagree' } + }); + } else { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { resultType: 'complete', content: [{ type: 'text', text: 'ok' }] } + }); + } + } + }; + await serverTx.start(); + return { clientTx, serverTx, callHeaders, listCount: () => lists }; +} + +function modernClient(store?: InMemoryResponseCacheStore): Client { + return new Client( + { name: 'param-mirror-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, ...(store && { responseCacheStore: store }) } + ); +} + +describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => { + it('listTools() and the cached tools/list entry exclude constraint-violating x-mcp-header tools and warn', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[REGION_TOOL, INVALID_TOOL]]); + const client = modernClient(store); + await client.connect(clientTx); + + // Auto-aggregate listTools() filters and writes the CACHED aggregate + // (the entry mirroring reads). + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['route']); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("excluding tool 'broken'")); + expect((store.get({ method: 'tools/list' })?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['route']); + // The explicit-cursor per-page path is filtered too (the spec's MUST + // has no carve-out for paginated reads). + const page = await client.listTools({ cursor: '0' }); + expect(page.tools.map(t => t.name)).toEqual(['route']); + warn.mockRestore(); + }); + + it('callTool() passes Mcp-Param-* via TransportSendOptions.headers from the cached tools/list entry; null/absent are omitted', async () => { + const { clientTx, callHeaders } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + await client.listTools(); + + await client.callTool({ name: 'route', arguments: { region: 'us-west1', query: 'x' } }); + await client.callTool({ name: 'route', arguments: { region: null, query: 'x' } as Record }); + + expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'us-west1' }); + expect(callHeaders[1]).toBeUndefined(); + }); + + it('callTool() uses the toolDefinition escape hatch without a prior tools/list', async () => { + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + await client.callTool({ name: 'route', arguments: { region: 'eu' } }, { toolDefinition: REGION_TOOL }); + expect(listCount()).toBe(0); + expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'eu' }); + }); + + it('callTool() evicts the tools/list entry, refetches once and retries on a HEADER_MISMATCH rejection (stale-cache path)', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]], /* rejectFirstCall */ true); + const client = modernClient(store); + await client.connect(clientTx); + // Seed a STALE entry (no declarations) so callTool reads it and the + // first send carries no param headers — server rejects + // HEADER_MISMATCH, client evicts, refetches via listTools() + // (the live REGION_TOOL), and retries with the headers. + store.set({ method: 'tools/list' }, { value: { tools: [{ name: 'route', inputSchema: { type: 'object', properties: {} } }] } }); + + const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + expect(listCount()).toBe(1); + expect(callHeaders).toEqual([undefined, { 'Mcp-Param-Region': 'ap' }]); + // The recovery refetch wrote a fresh cache entry (REGION_TOOL, with the declaration). + expect((store.get({ method: 'tools/list' })?.value as { tools: Tool[] }).tools[0]?.inputSchema.properties).toHaveProperty('region'); + }); + + it('callTool() with a cold cache issues NO tools/list and sends without Mcp-Param-* headers (cache reads only)', async () => { + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + // No on-demand populate: callTool reads the cache directly. Cold ⇒ + // proceed without headers (the spec's "client SHOULD send without + // custom headers" guidance) — the only callTool-driven tools/list is + // the HEADER_MISMATCH recovery path. + expect(listCount()).toBe(0); + expect(callHeaders).toEqual([undefined]); + }); + + it('a custom store whose get() rejects is routed to onerror and callTool degrades (no headers, no validation, result preserved)', async () => { + const store = new InMemoryResponseCacheStore(); + (store as ResponseCacheStore).get = () => Promise.reject(new Error('redis down')); + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(store); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await client.connect(clientTx); + + // The pre-send mirroring read AND the post-success validator read both + // hit a rejecting `get()`. Neither aborts the call: the request goes + // out without `Mcp-Param-*` headers (cold-cache posture), the + // server-side result is returned, and both store failures surface via + // `onerror`. The post-success guard is the critical one — a store + // failure after the server has executed the call must never surface + // as a `callTool()` rejection (duplicate-execution hazard on retry). + const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + expect(callHeaders).toEqual([undefined]); + expect(listCount()).toBe(0); + expect(errors.map(e => e.message)).toEqual(['redis down', 'redis down']); + }); + + it('a paginating server: the cached aggregate holds every page and a page-2 x-mcp-header tool mirrors on the first call', async () => { + const PAGE1: Tool = { name: 'echo', inputSchema: { type: 'object', properties: {} } }; + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[PAGE1], [REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['echo', 'route']); + expect(listCount()).toBe(2); + + await client.callTool({ name: 'route', arguments: { region: 'us-west1' } }); + expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'us-west1' }); + }); + + it('HEADER_MISMATCH recovery refetch walks every page; a page-2 x-mcp-header tool is recovered (stale-cache path)', async () => { + const PAGE1: Tool = { name: 'echo', inputSchema: { type: 'object', properties: {} } }; + const store = new InMemoryResponseCacheStore(); + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[PAGE1], [REGION_TOOL]], /* rejectFirstCall */ true); + const client = modernClient(store); + await client.connect(clientTx); + // Seed a STALE entry so the first send goes without headers; the + // recovery refetch (via listTools()) then walks BOTH pages. + store.set({ method: 'tools/list' }, { value: { tools: [PAGE1] } }); + + const result = await client.callTool({ name: 'route', arguments: { region: 'us-west1' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + // The recovery refetch walked both pages. + expect(listCount()).toBe(2); + expect(callHeaders).toEqual([undefined, { 'Mcp-Param-Region': 'us-west1' }]); + // A follow-up call still mirrors from the cached entry (no extra list). + await client.callTool({ name: 'route', arguments: { region: 'eu' } }); + expect(callHeaders[2]).toEqual({ 'Mcp-Param-Region': 'eu' }); + expect(listCount()).toBe(2); + }); + + it('notifications/tools/list_changed evicts the cached entry; the next callTool reads cold (no auto-refetch)', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(store); + await client.connect(clientTx); + // Seed a STALE entry (no declarations); list_changed evicts it; the + // next callTool reads cold and sends without headers — callTool + // never refetches on its own. + store.set({ method: 'tools/list' }, { value: { tools: [{ name: 'route', inputSchema: { type: 'object', properties: {} } }] } }); + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + + const result = await client.callTool({ name: 'route', arguments: { region: 'us' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + expect(listCount()).toBe(0); + expect(callHeaders).toEqual([undefined]); + }); + + it('_resetConnectionState() clears the response cache (close → reconnect → no stale scan)', async () => { + const a = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(a.clientTx); + await client.listTools(); + await client.close(); + + const b = await scriptedModernServer([[{ name: 'route', inputSchema: { type: 'object', properties: {} } }]]); + await client.connect(b.clientTx); + + await client.callTool({ name: 'route', arguments: { region: 'us' } }); + // The cache from A was cleared on close → callTool reads cold against + // server B → no Mcp-Param-* headers (no stale scan from A's entry), + // and no callTool-driven tools/list either. + expect(b.listCount()).toBe(0); + expect(b.callHeaders[0]).toBeUndefined(); + }); +}); + +describe('SEP-2243 era parity / stdio exemption', () => { + it('legacy-era callTool() is byte-untouched: zero tools/list requests, no headers, no exclusion', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const callHeaders: Array | undefined> = []; + const sentMethods: string[] = []; + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise => { + if ('method' in m) sentMethods.push((m as JSONRPCRequest).method); + if ((m as JSONRPCRequest).method === 'tools/call') callHeaders.push(opts?.headers ? { ...opts.headers } : undefined); + return realSend(m, opts); + }; + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'initialize') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 's', version: '1' } } + }); + } else if (r.method === 'tools/list') { + void serverTx.send({ jsonrpc: '2.0', id: r.id, result: { tools: [REGION_TOOL, INVALID_TOOL] } }); + } else if (r.method === 'tools/call') { + void serverTx.send({ jsonrpc: '2.0', id: r.id, result: { content: [{ type: 'text', text: 'ok' }] } }); + } + }; + await serverTx.start(); + + const client = new Client({ name: 'legacy', version: '1' }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + + // PIN: a legacy/stdio callTool issues ZERO tools/list requests — + // callTool never auto-populates the cache; mirroring/validation read + // it directly (cold ⇒ skip). + await client.callTool({ name: 'route', arguments: { region: 'us' } }); + expect(sentMethods.filter(m => m === 'tools/list')).toEqual([]); + expect(callHeaders).toEqual([undefined]); + + const { tools } = await client.listTools(); + // No exclusion on the legacy era — both tools present. + expect(tools.map(t => t.name)).toEqual(['route', 'broken']); + }); + + it('modern-era stdio callTool() issues zero tools/list requests (cold cache, mirroring inactive)', async () => { + // Mirrors the legacy pin above but on the modern era over a + // single-channel transport: even though `mirroringActive` is true, + // callTool reads the cache directly and sends nothing extra. + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + await client.callTool({ name: 'route', arguments: { region: 'us' } }); + expect(listCount()).toBe(0); + expect(callHeaders).toEqual([undefined]); + }); + + it('stdio MAY-ignore: a single-channel transport drops TransportSendOptions.headers', async () => { + // InMemoryTransport stands in for stdio here: like the stdio transport + // it shares a single channel and ignores per-request HTTP headers. + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + let sawHeaders: unknown; + serverTx.onmessage = (_m, extra) => { + sawHeaders = (extra as { headers?: unknown } | undefined)?.headers; + }; + await clientTx.start(); + await (clientTx as { send: (m: JSONRPCMessage, opts?: TransportSendOptions) => Promise }).send( + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'x' } }, + { headers: { 'Mcp-Param-Region': 'us' } } + ); + expect(sawHeaders).toBeUndefined(); + }); +}); + +describe('SEP-2243 Streamable HTTP transport seams', () => { + function transportWithCapture(): { tx: StreamableHTTPClientTransport; sent: () => Headers } { + let captured: Headers | undefined; + const fetch = vi.fn(async (_url, init) => { + captured = new Headers((init as RequestInit).headers); + return new Response(null, { status: 202, headers: { 'content-type': 'application/json' } }); + }); + const tx = new StreamableHTTPClientTransport(new URL('http://example.test/mcp'), { fetch: fetch as typeof globalThis.fetch }); + return { tx, sent: () => captured! }; + } + + const modernRequest = (method: string, params: Record): JSONRPCMessage => ({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } } + }); + + it('Mcp-Name is sentinel-encoded for non-ASCII / unsafe values (no Headers.set TypeError)', async () => { + const { tx, sent } = transportWithCapture(); + await tx.start(); + await tx.send(modernRequest('resources/read', { uri: 'file:///レポート.md' })); + expect(sent().get('mcp-name')).toBe(encodeMcpParamValue('file:///レポート.md')); + // ASCII-safe values pass through unchanged. + await tx.send(modernRequest('tools/call', { name: 'route', arguments: {} })); + expect(sent().get('mcp-name')).toBe('route'); + }); + + it('per-request TransportSendOptions.headers cannot override reserved standard/auth headers', async () => { + const { tx, sent } = transportWithCapture(); + await tx.start(); + await tx.send(modernRequest('tools/call', { name: 'route', arguments: {} }), { + headers: { 'Mcp-Method': 'tools/list', authorization: 'Bearer evil', 'Mcp-Param-Region': 'us' } + }); + expect(sent().get('mcp-method')).toBe('tools/call'); + expect(sent().get('authorization')).toBeNull(); + expect(sent().get('mcp-param-region')).toBe('us'); + }); + + it('an HTTP 400 carrying a JSON-RPC error response is delivered in-band on a modern-enveloped request; legacy still throws SdkHttpError', async () => { + const errorBody = { jsonrpc: '2.0', id: 1, error: { code: HEADER_MISMATCH_ERROR_CODE, message: 'Bad Request: …' } }; + const fetch = vi.fn( + async () => new Response(JSON.stringify(errorBody), { status: 400, headers: { 'content-type': 'application/json' } }) + ); + const tx = new StreamableHTTPClientTransport(new URL('http://example.test/mcp'), { fetch: fetch as typeof globalThis.fetch }); + const seen: JSONRPCMessage[] = []; + tx.onmessage = m => seen.push(m); + await tx.start(); + await expect(tx.send(modernRequest('tools/call', { name: 'route', arguments: {} }))).resolves.toBeUndefined(); + expect(seen[0]).toMatchObject({ id: 1, error: { code: HEADER_MISMATCH_ERROR_CODE } }); + + // Legacy-era exchange (no envelope claim) still surfaces 400 as the + // generic SdkHttpError — gating keeps the "legacy paths unchanged" + // claim true. + await expect(tx.send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'route' } })).rejects.toMatchObject({ + status: 400 + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0c9e2a22f5..54c133195e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export * from './shared/inboundClassification.js'; export * from './shared/inputRequired.js'; export * from './shared/inputRequiredDriver.js'; export * from './shared/inputRequiredEngine.js'; +export * from './shared/mcpParamHeaders.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/protocolEras.js'; diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 915eebf0b3..800d65941e 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -161,7 +161,8 @@ export type InboundValidationRung = | 'envelope' | 'method-registry' | 'request-params' - | 'client-capabilities'; + | 'client-capabilities' + | 'param-header-validation'; /** A ladder rejection: the JSON-RPC error to emit and the HTTP status to emit it with. */ export interface InboundLadderRejection { @@ -316,6 +317,21 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor 'only while the requirement table is empty: once a served method gains a requirement entry, a request that is ' + 'missing the capability and would also fail a dispatch rung is answered by this gate first, so the entry must ' + 'consult the method registry before the gate if the documented precedence is to stay observable.' + }, + { + rung: 'param-header-validation', + order: 8, + evaluatedAt: 'pre-dispatch', + codes: [HEADER_MISMATCH_ERROR_CODE], + conformance: ['http-custom-header-server-validation'], + rationale: + 'SEP-2243 `Mcp-Param-*` headers are validated against the named tool’s `x-mcp-header` declarations and the body ' + + '`arguments` after the tool registry is known and before dispatch reaches the handler; a missing/disagreeing/malformed ' + + 'header is rejected 400 / -32001 with the same shape as the standard-header cross-checks. The documented order ' + + '(after method resolution and params validation) is preserved observably only when the body `arguments` would ' + + 'otherwise validate: the check runs pre-dispatch, so a `tools/call` that fails BOTH this rung and a dispatch-time ' + + 'rung (e.g. order-6 `request-params`, -32602) is answered by this gate first with 400 / -32001, not by the ' + + 'earlier-ordered rung.' } ]; diff --git a/packages/core/src/shared/inputRequiredEngine.ts b/packages/core/src/shared/inputRequiredEngine.ts index f71c8d471a..d58c531d97 100644 --- a/packages/core/src/shared/inputRequiredEngine.ts +++ b/packages/core/src/shared/inputRequiredEngine.ts @@ -176,6 +176,11 @@ export function buildRetryLegRequestOptions(options: RequestOptions | undefined, ...(options?.signal !== undefined && { signal: options.signal }), ...(options?.onprogress !== undefined && { onprogress: options.onprogress }), ...(options?.resetTimeoutOnProgress !== undefined && { resetTimeoutOnProgress: options.resetTimeoutOnProgress }), + // Per-request HTTP headers (SEP-2243 `Mcp-Param-*`) carry over: the + // retry's `arguments` are byte-identical to the originating leg (the + // driver only adds `inputResponses`/`requestState`), so the param + // headers built for the first leg remain correct for every retry leg. + ...(options?.headers !== undefined && { headers: options.headers }), ...(legOptions.timeout !== undefined && { timeout: legOptions.timeout }), ...(legOptions.maxTotalTimeout !== undefined && { maxTotalTimeout: legOptions.maxTotalTimeout }), // The driver re-enters the funnel with the manual primitive: a further diff --git a/packages/core/src/shared/mcpParamHeaders.ts b/packages/core/src/shared/mcpParamHeaders.ts new file mode 100644 index 0000000000..6f6ec7f294 --- /dev/null +++ b/packages/core/src/shared/mcpParamHeaders.ts @@ -0,0 +1,412 @@ +/** + * SEP-2243 `Mcp-Param-*` header codec (protocol revision 2026-07-28). + * + * Pure functions for the custom-header half of SEP-2243: scanning a tool's + * `inputSchema` for `x-mcp-header` declarations, encoding argument values into + * `Mcp-Param-{Name}` HTTP headers (with the `=?base64?…?=` sentinel for values + * that cannot be safely represented as plain ASCII field values), decoding + * those headers, and validating that the headers a request carries match the + * argument values in its body. + * + * The standard-header half (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`) + * lives with the inbound classifier — this module is the custom-header half + * only, and it consumes the same `-32001` (`HeaderMismatch`) emission shape the + * classifier established for header/body cross-check failures. + * + * Spec text at the implementation's spec pin: + * - draft/basic/transports/streamable-http.mdx § "Custom Headers from Tool Parameters" + * (constraints, value encoding, the 5-step client algorithm, the + * server-behavior table, the `400` + `-32001` rejection) + * - draft/server/tools.mdx § "x-mcp-header" (the schema-extension property and + * its constraints) + */ +import type { InboundLadderRejection } from './inboundClassification.js'; +import { HEADER_MISMATCH_ERROR_CODE } from './inboundClassification.js'; + +/* ------------------------------------------------------------------------ * + * Declaration scan + * ------------------------------------------------------------------------ */ + +/** The fixed prefix every custom-parameter header carries. */ +export const MCP_PARAM_HEADER_PREFIX = 'Mcp-Param-'; + +/** The schema-extension property name a tool's `inputSchema` carries. */ +export const X_MCP_HEADER_KEY = 'x-mcp-header'; + +/** + * One `x-mcp-header` declaration found inside a tool's `inputSchema`. + * + * `path` is the property path from the arguments root (the spec permits + * declarations at any nesting depth under `properties`); `headerName` is the + * `{Name}` portion as declared (case preserved for emission; comparison is + * case-insensitive); `type` is the JSON Schema `type` of the declaring + * property. + */ +export interface XMcpHeaderDeclaration { + path: readonly string[]; + headerName: string; + type: string; +} + +/** The result of scanning a tool's `inputSchema` for `x-mcp-header` declarations. */ +export type XMcpHeaderScanResult = { valid: true; declarations: readonly XMcpHeaderDeclaration[] } | { valid: false; reason: string }; + +/** + * RFC 9110 §5.1 `token` syntax (`1*tchar`). Rejects empty, space, control + * characters (including CR/LF), and the listed delimiters. + */ +const RFC9110_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** + * JSON Schema `type` values the spec admits on an `x-mcp-header` property. + * + * The spec text names `integer`, `string`, `boolean` and explicitly excludes + * `number`. The published conformance referee at the pinned release ships its + * `http-custom-headers` scenario with two `type: "number"` `x-mcp-header` + * parameters and expects the client to mirror them, so `number` is accepted + * here so that the conformance gate passes; the discrepancy is tracked + * upstream. Everything else (`object`, `array`, `null`, absent) is rejected. + */ +const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set(['string', 'integer', 'boolean', 'number']); + +/** + * Scan a tool's JSON-serialized `inputSchema` for `x-mcp-header` declarations + * and validate every constraint the spec places on them. Returns either the + * collected declarations (possibly empty) or the first violated constraint. + * + * The walk descends through `properties` at any depth (the spec's "any nesting + * depth" clause). The static-reachability MUST is enforced as a structural + * sweep: every position the chain MUST NOT pass through (`items`/ + * `additionalProperties`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, + * `$defs`, `$ref` targets within `$defs`) is visited too, and an + * `x-mcp-header` found anywhere on that path invalidates the schema — "an + * annotation anywhere else makes the tool definition invalid". + */ +export function scanXMcpHeaderDeclarations(inputSchema: unknown): XMcpHeaderScanResult { + const declarations: XMcpHeaderDeclaration[] = []; + const seenLower = new Map(); + + const visit = (node: unknown, path: readonly string[], reachable: boolean): string | undefined => { + if (node === null || typeof node !== 'object') return undefined; + const schema = node as Record; + + if (X_MCP_HEADER_KEY in schema) { + if (!reachable || path.length === 0) { + return `${pathName(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of 'properties' keys (not under items, additionalProperties, oneOf/anyOf/allOf/not, if/then/else, or $ref)`; + } + const raw = schema[X_MCP_HEADER_KEY]; + if (typeof raw !== 'string' || raw.length === 0) { + return `${pathName(path)}: x-mcp-header MUST be a non-empty string`; + } + if (!RFC9110_TOKEN.test(raw)) { + return `${pathName(path)}: x-mcp-header '${raw}' is not a valid RFC 9110 token (no spaces, control characters or HTTP delimiters)`; + } + const type = typeof schema.type === 'string' ? schema.type : undefined; + if (type === undefined || !PERMITTED_X_MCP_HEADER_TYPES.has(type)) { + return `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? ''}`; + } + const lower = raw.toLowerCase(); + const prior = seenLower.get(lower); + if (prior !== undefined) { + return `x-mcp-header '${raw}' is not case-insensitively unique (also declared as '${prior}')`; + } + seenLower.set(lower, raw); + declarations.push({ path, headerName: raw, type }); + } + + const properties = schema.properties; + if (properties !== null && typeof properties === 'object') { + for (const [key, child] of Object.entries(properties as Record)) { + const fault = visit(child, [...path, key], reachable); + if (fault !== undefined) return fault; + } + } + // Static-reachability sweep: descend the keywords the chain MUST NOT + // pass through with `reachable: false` so an annotation under any of + // them is reported (rather than silently ignored). `$defs` covers + // `$ref`-within-`$defs` — chasing arbitrary `$ref` URIs is out of scope. + for (const k of NON_REACHABLE_SUBSCHEMA_KEYWORDS) { + const sub = schema[k]; + if (sub === undefined) continue; + const branches: unknown[] = Array.isArray(sub) + ? sub + : sub !== null && typeof sub === 'object' && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.has(k) + ? Object.values(sub as Record) + : [sub]; + for (const branch of branches) { + const fault = visit(branch, [...path, `<${k}>`], false); + if (fault !== undefined) return fault; + } + } + return undefined; + }; + + const fault = visit(inputSchema, [], true); + return fault === undefined ? { valid: true, declarations } : { valid: false, reason: fault }; +} + +/** + * JSON Schema keywords whose subschemas the SEP-2243 static-reachability + * constraint excludes from the `properties`-only chain. An `x-mcp-header` + * found under any of these invalidates the tool definition. + */ +const NON_REACHABLE_SUBSCHEMA_KEYWORDS = [ + 'items', + 'prefixItems', + 'contains', + 'additionalProperties', + 'unevaluatedProperties', + 'unevaluatedItems', + 'propertyNames', + 'patternProperties', + 'dependentSchemas', + 'oneOf', + 'anyOf', + 'allOf', + 'not', + 'if', + 'then', + 'else', + '$defs', + 'definitions' +] as const; + +/** + * Subschema-carrying keywords whose value is a `name → subschema` object + * (not a single subschema or array of subschemas). The visit branches over + * `Object.values()` for these. + */ +const OBJECT_VALUED_SUBSCHEMA_KEYWORDS: ReadonlySet = new Set(['patternProperties', 'dependentSchemas', '$defs', 'definitions']); + +function pathName(path: readonly string[]): string { + return path.length === 0 ? '' : path.join('.'); +} + +/* ------------------------------------------------------------------------ * + * Value encoding + * ------------------------------------------------------------------------ */ + +const BASE64_SENTINEL_PREFIX = '=?base64?'; +const BASE64_SENTINEL_SUFFIX = '?='; +// RFC 4648 §4, padding required (the spec's encoding-examples table and the +// conformance referee's invalid-padding cell both require canonical padding). +const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +/** + * Convert a primitive argument value to its string representation per the + * spec's type-conversion rules: strings pass through, integers and numbers + * become their decimal string, booleans become lowercase `'true'` / `'false'`. + * Non-finite numbers and integers outside the safe range are refused (the + * caller treats `undefined` as "do not emit a header for this value"). + */ +export function mcpParamPrimitiveToString(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') { + if (!Number.isFinite(value)) return undefined; + if (Number.isInteger(value) && !Number.isSafeInteger(value)) return undefined; + return String(value); + } + return undefined; +} + +/** + * `true` when `s` cannot be safely represented as a plain ASCII HTTP field + * value per RFC 9110 §5.5: it contains a byte outside `0x20–0x7E` / `0x09`, it + * has leading or trailing whitespace (which field parsing strips), or it + * already matches the Base64 sentinel pattern (the spec's "to avoid ambiguity" + * rule). + */ +function needsBase64(s: string): boolean { + if (s.length === 0) return true; + if (s.startsWith(BASE64_SENTINEL_PREFIX) && s.endsWith(BASE64_SENTINEL_SUFFIX)) return true; + if (s !== s.trim()) return true; + for (let i = 0; i < s.length; i++) { + const c = s.codePointAt(i)!; + // Visible ASCII 0x21–0x7E, plus space 0x20 and horizontal tab 0x09; a + // tab is only safe when it is interior whitespace (the trim() check + // above already covered leading/trailing). + if (c === 0x09 || (c >= 0x20 && c <= 0x7e)) continue; + return true; + } + return false; +} + +function utf8ToBase64(s: string): string { + const bytes = new TextEncoder().encode(s); + let bin = ''; + for (const b of bytes) bin += String.fromCodePoint(b); + return btoa(bin); +} + +function base64ToUtf8(b64: string): string { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.codePointAt(i)!; + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); +} + +/** + * Encode a string value as an HTTP field value per the spec's value-encoding + * rules: a value that is already a safe plain-ASCII field value is passed + * through unchanged; anything else is wrapped as `=?base64?{b64-of-utf8}?=`. + */ +export function encodeMcpParamValue(value: string): string { + return needsBase64(value) ? `${BASE64_SENTINEL_PREFIX}${utf8ToBase64(value)}${BASE64_SENTINEL_SUFFIX}` : value; +} + +/** + * Decode an `Mcp-Param-*` header value: when it carries the Base64 sentinel, + * the payload is decoded as UTF-8; otherwise the value is returned as-is. + * Returns `undefined` when the sentinel is present but the payload is not + * canonical Base64 (or not valid UTF-8) — the spec requires servers to reject + * such values. + */ +export function decodeMcpParamValue(value: string): string | undefined { + if (!(value.startsWith(BASE64_SENTINEL_PREFIX) && value.endsWith(BASE64_SENTINEL_SUFFIX))) { + return value; + } + const b64 = value.slice(BASE64_SENTINEL_PREFIX.length, value.length - BASE64_SENTINEL_SUFFIX.length); + if (!BASE64_CANONICAL.test(b64)) return undefined; + try { + return base64ToUtf8(b64); + } catch { + return undefined; + } +} + +/* ------------------------------------------------------------------------ * + * Client-side header construction (the 5-step MUST algorithm, steps 3–5) + * ------------------------------------------------------------------------ */ + +function valueAtPath(root: unknown, path: readonly string[]): unknown { + let node: unknown = root; + for (const key of path) { + if (node === null || typeof node !== 'object') return undefined; + node = (node as Record)[key]; + } + return node; +} + +/** + * Build the `Mcp-Param-{Name}` headers for one `tools/call` from a scan of the + * tool's `inputSchema` and the call's `arguments`. A declaration whose value is + * `null` or absent in `arguments` is omitted (the spec's "client MUST omit the + * header" rows); a value that is not a primitive of the declared kind is + * omitted rather than emitted malformed. + */ +export function buildMcpParamHeaders( + declarations: readonly XMcpHeaderDeclaration[], + args: Record | undefined +): Record { + const out: Record = {}; + for (const decl of declarations) { + const raw = valueAtPath(args, decl.path); + if (raw === undefined || raw === null) continue; + const stringValue = mcpParamPrimitiveToString(raw); + if (stringValue === undefined) continue; + out[`${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`] = encodeMcpParamValue(stringValue); + } + return out; +} + +/* ------------------------------------------------------------------------ * + * Server-side validation + * ------------------------------------------------------------------------ */ + +/** + * The header/body comparison the server performs at tool-resolution time. + * + * For each `x-mcp-header` declaration on the named tool: when the body + * `arguments` carries a value, the matching `Mcp-Param-{Name}` header MUST be + * present and decode to an equal value; when the body value is `null` or + * absent the server MUST NOT expect the header (a present header is ignored). + * A sentinel-carrying header whose payload is not canonical Base64 / valid + * UTF-8 is rejected as invalid characters. + * + * Integer-typed declarations are compared numerically (the spec's SHOULD — + * `42.0` and `42` are equal); everything else is compared as decoded strings. + * + * Returns `undefined` when every check passes, or an + * {@linkcode InboundLadderRejection} carrying the same `-32001` + * (`HeaderMismatch`) shape the inbound classifier emits for the + * standard-header cross-checks — `400 Bad Request` with the disagreeing pair + * in `data.mismatch`. + */ +export function validateMcpParamHeaders( + declarations: readonly XMcpHeaderDeclaration[], + args: Record | undefined, + headers: Headers +): InboundLadderRejection | undefined { + for (const decl of declarations) { + const headerKey = `${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`; + const headerValue = headers.get(headerKey); + const bodyRaw = valueAtPath(args, decl.path); + + if (bodyRaw === undefined || bodyRaw === null) { + // Server MUST NOT expect the header for a null/absent value. + continue; + } + const bodyString = mcpParamPrimitiveToString(bodyRaw); + if (bodyString === undefined) { + // Body carries a non-primitive where the schema declares one; + // params validation owns that fault. Skip the header check. + continue; + } + if (headerValue === null) { + return paramHeaderMismatchRejection( + 'param-header-missing', + headerKey, + `the body carries ${pathName(decl.path)}=${JSON.stringify(bodyRaw)} but the ${headerKey} header is absent` + ); + } + const decoded = decodeMcpParamValue(headerValue); + if (decoded === undefined) { + return paramHeaderMismatchRejection( + 'param-header-invalid-encoding', + headerKey, + `the ${headerKey} header carries an invalid Base64 sentinel value` + ); + } + // Integer/number-typed declarations compare numerically (the spec's + // SHOULD — `42.0` and `42` are equal), but only when both sides parse + // to finite numbers. A non-numeric primitive (e.g. `'abc'` where the + // schema declares `integer`) is a body-vs-schema fault that params + // validation owns; comparing `NaN === NaN` would wrongly report a + // header/body mismatch for an identical pair, so fall back to string + // comparison and let dispatch emit `-32602` instead. + const decodedNum = Number(decoded); + const bodyNum = Number(bodyString); + const numericComparable = + (decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum); + const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString; + if (!equal) { + return paramHeaderMismatchRejection( + 'param-header-mismatch', + headerKey, + `the ${headerKey} header decodes to ${JSON.stringify(decoded)} but the body carries ${pathName(decl.path)}=${JSON.stringify(bodyRaw)}` + ); + } + } + return undefined; +} + +/** + * Build the `-32001` (`HeaderMismatch`) rejection for an `Mcp-Param-*` + * disagreement. Same shape as the inbound classifier's standard-header + * cross-check mismatch (HTTP `400`, `data.mismatch` naming the disagreeing + * pair, `settled: true`); only the rung differs because this check runs at the + * pre-dispatch step against a known tool's schema rather than at the edge. + */ +export function paramHeaderMismatchRejection(cell: string, header: string, body: string): InboundLadderRejection { + return { + kind: 'reject', + rung: 'param-header-validation', + cell, + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + message: `Bad Request: the request headers and body disagree: ${body}`, + data: { mismatch: { header, body } }, + settled: true + }; +} diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 356d63ea08..df8d3d8073 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1269,7 +1269,7 @@ export abstract class Protocol { resultSchema: T, options?: RequestOptions ): Promise> { - const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; + const { relatedRequestId, resumptionToken, onresumptiontoken, headers } = options ?? {}; // Flow start for non-complete result resolution: `maxTotalTimeout` // bounds the WHOLE flow, so the budget is measured from the original // request, not from when an extension takes over after the first leg. @@ -1430,7 +1430,7 @@ export abstract class Protocol { this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._transport.send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken, headers }).catch(error => { this._progressHandlers.delete(messageId); reject(error); }); diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index c9be6ee56c..fdb6fc3dd4 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -87,6 +87,19 @@ export type TransportSendOptions = { * (stdio, in-memory) ignore it. */ onRequestStreamEnd?: (() => void) | undefined; + + /** + * Additional HTTP headers to send with THIS outbound message, when the + * transport sends one outbound message per underlying HTTP request (the + * Streamable HTTP transport's POST-per-request model). Transports that + * share a single channel (stdio, in-memory) ignore it. + * + * The Client uses this to attach SEP-2243 `Mcp-Param-{Name}` headers to a + * `tools/call` request on a 2026-07-28 connection. Values are sent + * verbatim — encode anything that is not a safe RFC 9110 field value + * before passing it here. + */ + headers?: Readonly> | undefined; }; /** * Describes the minimal contract for an MCP transport that a client or server can communicate over. diff --git a/packages/core/test/shared/inputRequiredEngine.test.ts b/packages/core/test/shared/inputRequiredEngine.test.ts index af4f9eeed7..2ba089808c 100644 --- a/packages/core/test/shared/inputRequiredEngine.test.ts +++ b/packages/core/test/shared/inputRequiredEngine.test.ts @@ -48,6 +48,12 @@ describe('per-retry-leg request options whitelist', () => { test('absent caller options yield only the manual primitive opt-in', () => { expect(buildRetryLegRequestOptions(undefined, {})).toEqual({ allowInputRequired: true }); }); + + test('per-request headers (SEP-2243 Mcp-Param-*) carry to retry legs — arguments are unchanged on retry', () => { + const headers = { 'Mcp-Param-Region': 'us-west1' }; + const built = buildRetryLegRequestOptions({ headers }, {}); + expect(built).toEqual({ headers, allowInputRequired: true }); + }); }); describe('inputResponses partition', () => { diff --git a/packages/core/test/shared/mcpParamHeaders.test.ts b/packages/core/test/shared/mcpParamHeaders.test.ts new file mode 100644 index 0000000000..4e76ff0ee6 --- /dev/null +++ b/packages/core/test/shared/mcpParamHeaders.test.ts @@ -0,0 +1,330 @@ +/** + * SEP-2243 `Mcp-Param-*` codec — fixture corpus. + * + * Encoding rows mirror the spec's "Encoding examples" table (and the + * sentinel-collision rule); the constraint rows mirror the published + * conformance referee's `http-invalid-tool-headers` scenario; the + * server-validation rows cover the spec's server-behavior table including the + * two checks the conformance manifest leaves globally untested + * (`sep-2243-server-not-expect-null`, `sep-2243-server-reject-missing-required`). + */ +import { describe, expect, test } from 'vitest'; + +import { HEADER_MISMATCH_ERROR_CODE } from '../../src/shared/inboundClassification.js'; +import { + buildMcpParamHeaders, + decodeMcpParamValue, + encodeMcpParamValue, + MCP_PARAM_HEADER_PREFIX, + mcpParamPrimitiveToString, + paramHeaderMismatchRejection, + scanXMcpHeaderDeclarations, + validateMcpParamHeaders, + X_MCP_HEADER_KEY +} from '../../src/shared/mcpParamHeaders.js'; + +/* ------------------------------------------------------------------------ * + * Value encoding (spec table) + * ------------------------------------------------------------------------ */ + +describe('encodeMcpParamValue / decodeMcpParamValue — spec encoding-examples table', () => { + const CASES: ReadonlyArray<[label: string, input: string, expected: string]> = [ + ['plain ASCII passes through', 'us-west1', 'us-west1'], + ['non-ASCII is Base64-wrapped', 'Hello, 世界', '=?base64?SGVsbG8sIOS4lueVjA==?='], + ['leading + trailing whitespace is Base64-wrapped', ' padded ', '=?base64?IHBhZGRlZCA=?='], + ['embedded newline is Base64-wrapped', 'line1\nline2', '=?base64?bGluZTEKbGluZTI=?='], + ['a value matching the sentinel pattern is itself Base64-wrapped', '=?base64?literal?=', '=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?='], + ['the empty string is Base64-wrapped (would otherwise vanish on the wire)', '', '=?base64??='], + ['internal-only spaces stay plain ASCII (RFC 9110 admits SP inside a field value)', 'a b c', 'a b c'], + ['leading-only space is Base64-wrapped', ' lead', `=?base64?${btoa(' lead')}?=`], + ['trailing-only space is Base64-wrapped', 'trail ', `=?base64?${btoa('trail ')}?=`], + ['CR/LF is Base64-wrapped', 'a\r\nb', `=?base64?${btoa('a\r\nb')}?=`], + ['leading tab is Base64-wrapped', '\tindent', `=?base64?${btoa('\tindent')}?=`] + ]; + + for (const [label, input, expected] of CASES) { + test(label, () => { + const encoded = encodeMcpParamValue(input); + expect(encoded).toBe(expected); + expect(decodeMcpParamValue(encoded)).toBe(input); + }); + } + + test('decode passes a non-sentinel value through unchanged', () => { + expect(decodeMcpParamValue('us-west1')).toBe('us-west1'); + }); + + test('CRLF header-injection: encode produces a sentinel value with no CR/LF and round-trips intact', () => { + // Mcp-Param-* and Mcp-Name share this encoder; an attacker-controlled + // value with CR/LF MUST encode to a header-safe form (RFC 9110 token + // alphabet for the sentinel framing, RFC 4648 §4 alphabet for the + // payload — neither contains CR/LF) so it cannot inject a header. + const injection = 'foo\r\nX-Injected: bar'; + const encoded = encodeMcpParamValue(injection); + expect(encoded.startsWith('=?base64?')).toBe(true); + expect(encoded).not.toMatch(/[\r\n]/); + expect(decodeMcpParamValue(encoded)).toBe(injection); + // The Mcp-Name encoding path is the same encodeMcpParamValue call + // (`_applyBodyDerivedHeaders` in the client transport); pin the + // header-safety property here so a future encoder change cannot + // regress it silently. + expect(() => new Headers().set('mcp-name', encoded)).not.toThrow(); + }); + + test('decode rejects invalid Base64 padding inside the sentinel', () => { + expect(decodeMcpParamValue('=?base64?SGVsbG8?=')).toBeUndefined(); + }); + + test('decode rejects non-alphabet characters inside the sentinel', () => { + expect(decodeMcpParamValue('=?base64?SGV%%G8=?=')).toBeUndefined(); + }); +}); + +describe('mcpParamPrimitiveToString — type-conversion rules', () => { + test('string passes through', () => expect(mcpParamPrimitiveToString('a')).toBe('a')); + test('boolean true → "true"', () => expect(mcpParamPrimitiveToString(true)).toBe('true')); + test('boolean false → "false"', () => expect(mcpParamPrimitiveToString(false)).toBe('false')); + test('integer → decimal string', () => expect(mcpParamPrimitiveToString(42)).toBe('42')); + test('negative integer → decimal string', () => expect(mcpParamPrimitiveToString(-7)).toBe('-7')); + test('non-finite is refused', () => expect(mcpParamPrimitiveToString(Number.POSITIVE_INFINITY)).toBeUndefined()); + test('integer outside ±(2^53-1) is refused', () => expect(mcpParamPrimitiveToString(2 ** 53)).toBeUndefined()); + test('object is refused', () => expect(mcpParamPrimitiveToString({})).toBeUndefined()); +}); + +/* ------------------------------------------------------------------------ * + * Declaration scan (constraint rows from http-invalid-tool-headers) + * ------------------------------------------------------------------------ */ + +describe('scanXMcpHeaderDeclarations — constraint table', () => { + const valid = (schema: unknown) => { + const r = scanXMcpHeaderDeclarations(schema); + expect(r.valid).toBe(true); + return r.valid ? r.declarations : []; + }; + const invalid = (schema: unknown) => { + const r = scanXMcpHeaderDeclarations(schema); + expect(r.valid).toBe(false); + return r.valid ? '' : r.reason; + }; + + test('a valid declaration is collected', () => { + const decls = valid({ type: 'object', properties: { region: { type: 'string', [X_MCP_HEADER_KEY]: 'Region' } } }); + expect(decls).toEqual([{ path: ['region'], headerName: 'Region', type: 'string' }]); + }); + + test('declarations at any nesting depth are collected', () => { + const decls = valid({ + type: 'object', + properties: { + outer: { type: 'object', properties: { inner: { type: 'string', [X_MCP_HEADER_KEY]: 'Inner' } } } + } + }); + expect(decls).toEqual([{ path: ['outer', 'inner'], headerName: 'Inner', type: 'string' }]); + }); + + test('a schema with no declarations scans valid with an empty list', () => { + expect(valid({ type: 'object', properties: { a: { type: 'string' } } })).toEqual([]); + }); + + test('empty x-mcp-header value is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'string', [X_MCP_HEADER_KEY]: '' } } })).toMatch(/non-empty/); + }); + + test('non-token x-mcp-header value (space) is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'string', [X_MCP_HEADER_KEY]: 'My Region' } } })).toMatch( + /RFC 9110 token/ + ); + }); + + test('object-typed property is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'object', [X_MCP_HEADER_KEY]: 'Data' } } })).toMatch(/primitive/); + }); + + test('array-typed property is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'array', [X_MCP_HEADER_KEY]: 'Items' } } })).toMatch(/primitive/); + }); + + test('null-typed property is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'null', [X_MCP_HEADER_KEY]: 'Nil' } } })).toMatch(/primitive/); + }); + + // Static-reachability MUST: an x-mcp-header anywhere outside the + // properties-only chain invalidates the tool definition. + const REACHABILITY_CASES: ReadonlyArray<[label: string, schema: unknown]> = [ + ['root schema', { type: 'object', [X_MCP_HEADER_KEY]: 'Root' }], + ['under items', { type: 'object', properties: { a: { type: 'array', items: { type: 'string', [X_MCP_HEADER_KEY]: 'Elem' } } } }], + [ + 'under additionalProperties', + { type: 'object', properties: {}, additionalProperties: { type: 'string', [X_MCP_HEADER_KEY]: 'Extra' } } + ], + [ + 'under oneOf', + { type: 'object', oneOf: [{ type: 'object', properties: { a: { type: 'string', [X_MCP_HEADER_KEY]: 'Branch' } } }] } + ], + ['under anyOf', { type: 'object', anyOf: [{ type: 'string', [X_MCP_HEADER_KEY]: 'Branch' }] }], + ['under allOf', { type: 'object', allOf: [{ type: 'string', [X_MCP_HEADER_KEY]: 'Branch' }] }], + ['under not', { type: 'object', not: { type: 'string', [X_MCP_HEADER_KEY]: 'Neg' } }], + ['under if/then/else', { type: 'object', if: {}, then: { type: 'string', [X_MCP_HEADER_KEY]: 'Cond' } }], + [ + 'under $defs (a $ref-within-$defs target)', + { type: 'object', properties: { a: { $ref: '#/$defs/R' } }, $defs: { R: { type: 'string', [X_MCP_HEADER_KEY]: 'Ref' } } } + ], + [ + "under draft-07 'definitions' (legacy alias of $defs)", + { + type: 'object', + properties: { a: { $ref: '#/definitions/R' } }, + definitions: { R: { type: 'string', [X_MCP_HEADER_KEY]: 'Ref' } } + } + ], + [ + 'under dependentSchemas', + { + type: 'object', + dependentSchemas: { foo: { type: 'object', properties: { bar: { type: 'string', [X_MCP_HEADER_KEY]: 'Dep' } } } } + } + ], + ['under unevaluatedProperties', { type: 'object', unevaluatedProperties: { type: 'string', [X_MCP_HEADER_KEY]: 'Unev' } }], + [ + 'under unevaluatedItems', + { type: 'object', properties: { a: { type: 'array', unevaluatedItems: { type: 'string', [X_MCP_HEADER_KEY]: 'Unev' } } } } + ], + ['under propertyNames', { type: 'object', propertyNames: { type: 'string', [X_MCP_HEADER_KEY]: 'PNames' } }], + [ + 'nested: properties → items → properties (the chain passes through items)', + { + type: 'object', + properties: { + a: { type: 'array', items: { type: 'object', properties: { b: { type: 'string', [X_MCP_HEADER_KEY]: 'Deep' } } } } + } + } + ] + ]; + for (const [label, schema] of REACHABILITY_CASES) { + test(`x-mcp-header on a non-statically-reachable position is rejected: ${label}`, () => { + expect(invalid(schema)).toMatch(/statically reachable/); + }); + } + + test('case-insensitively duplicated header name is rejected', () => { + expect( + invalid({ + type: 'object', + properties: { + a: { type: 'string', [X_MCP_HEADER_KEY]: 'MyField' }, + b: { type: 'string', [X_MCP_HEADER_KEY]: 'myfield' } + } + }) + ).toMatch(/unique/); + }); +}); + +/* ------------------------------------------------------------------------ * + * buildMcpParamHeaders — null/absent omission, primitive emission + * ------------------------------------------------------------------------ */ + +describe('buildMcpParamHeaders', () => { + const DECLS = [ + { path: ['region'], headerName: 'Region', type: 'string' }, + { path: ['priority'], headerName: 'Priority', type: 'integer' }, + { path: ['verbose'], headerName: 'Verbose', type: 'boolean' } + ] as const; + + test('present primitive values become headers; null and absent are omitted', () => { + expect(buildMcpParamHeaders(DECLS, { region: 'us-west1', priority: 5, verbose: null })).toEqual({ + 'Mcp-Param-Region': 'us-west1', + 'Mcp-Param-Priority': '5' + }); + }); + + test('a non-primitive value is silently omitted (params validation owns that fault)', () => { + expect(buildMcpParamHeaders([{ path: ['region'], headerName: 'Region', type: 'string' }], { region: { x: 1 } })).toEqual({}); + }); +}); + +/* ------------------------------------------------------------------------ * + * Server-side validation — the spec's server-behavior table + * ------------------------------------------------------------------------ */ + +describe('validateMcpParamHeaders — server-behavior table', () => { + const DECLS = [{ path: ['region'], headerName: 'Region', type: 'string' }] as const; + + test('header present and matching → ok', () => { + const headers = new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'us-west1' }); + expect(validateMcpParamHeaders(DECLS, { region: 'us-west1' }, headers)).toBeUndefined(); + }); + + test('header decodes from Base64 and matches → ok', () => { + const headers = new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: encodeMcpParamValue('Hello, 世界') }); + expect(validateMcpParamHeaders(DECLS, { region: 'Hello, 世界' }, headers)).toBeUndefined(); + }); + + // sep-2243-server-not-expect-null — globally-untested manifest check, covered here. + test('body value null → server MUST NOT expect the header (a stray header is ignored)', () => { + const headers = new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'whatever' }); + expect(validateMcpParamHeaders(DECLS, { region: null }, headers)).toBeUndefined(); + expect(validateMcpParamHeaders(DECLS, {}, new Headers())).toBeUndefined(); + }); + + // sep-2243-server-reject-missing-required — globally-untested manifest check, covered here. + test('body has the value but the header is absent → reject 400/-32001', () => { + const r = validateMcpParamHeaders(DECLS, { region: 'us-west1' }, new Headers()); + expect(r).toMatchObject({ kind: 'reject', httpStatus: 400, code: HEADER_MISMATCH_ERROR_CODE, cell: 'param-header-missing' }); + }); + + test('header present but disagreeing → reject 400/-32001 with the mismatch in data', () => { + const r = validateMcpParamHeaders(DECLS, { region: 'us-west1' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'eu' })); + expect(r).toMatchObject({ + kind: 'reject', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + cell: 'param-header-mismatch', + data: { mismatch: { header: 'Mcp-Param-Region' } } + }); + }); + + test('invalid Base64 sentinel → reject 400/-32001', () => { + const r = validateMcpParamHeaders( + DECLS, + { region: 'Hello' }, + new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: '=?base64?SGVsbG8?=' }) + ); + expect(r).toMatchObject({ + kind: 'reject', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + cell: 'param-header-invalid-encoding' + }); + }); + + test('integer-typed declarations are compared numerically (42.0 == 42)', () => { + const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const; + expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined(); + }); + + test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => { + const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const; + // Identical header/body — must NOT report a header/body disagreement; + // params validation owns the body-vs-schema fault. + expect(validateMcpParamHeaders(intDecl, { n: 'abc' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: 'abc' }))).toBeUndefined(); + // Different values still reject as a mismatch. + const r = validateMcpParamHeaders(intDecl, { n: 'abc' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: 'xyz' })); + expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' }); + }); +}); + +describe('paramHeaderMismatchRejection — consumes the inbound-classifier −32001 shape verbatim', () => { + test('shape: 400 / -32001 / settled, with data.mismatch and the same message prefix', () => { + const r = paramHeaderMismatchRejection('param-header-mismatch', 'Mcp-Param-Region', 'body says us-west1'); + expect(r).toEqual({ + kind: 'reject', + rung: 'param-header-validation', + cell: 'param-header-mismatch', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + message: 'Bad Request: the request headers and body disagree: body says us-west1', + data: { mismatch: { header: 'Mcp-Param-Region', body: 'body says us-west1' } }, + settled: true + }); + }); +}); diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 3afd69fbf1..5d02871a6e 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -46,11 +46,13 @@ import { modernOnlyStrictRejection, requestMetaOf, requiredClientCapabilitiesForRequest, + scanXMcpHeaderDeclarations, SdkError, SdkErrorCode, setNegotiatedProtocolVersion, SUPPORTED_MODERN_PROTOCOL_VERSIONS, - UnsupportedProtocolVersionError + UnsupportedProtocolVersionError, + validateMcpParamHeaders } from '@modelcontextprotocol/core'; import { invoke } from './invoke.js'; @@ -693,6 +695,33 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return listenRouter.serve(route.message, request.signal, capabilities); } + // SEP-2243 `Mcp-Param-*` server-side validation (pre-dispatch ladder + // rung): for a `tools/call`, look up the named tool's JSON inputSchema + // on the just-produced instance and compare every `x-mcp-header` + // declaration against the request's `Mcp-Param-{Name}` headers and the + // body `arguments`. A mismatch (or a missing header for a present body + // value, or an invalid Base64 sentinel) emits the same `400` / + // `-32001` (`HeaderMismatch`) shape the edge cross-checks use. Only + // applied when the factory returns an `McpServer` (the registry is the + // schema source); a low-level `Server` factory has no registry, so + // there is nothing to validate against. + if (route.messageKind === 'request' && route.message.method === 'tools/call' && product instanceof McpServer) { + const callParams = route.message.params as { name?: string; arguments?: Record } | undefined; + const toolName = typeof callParams?.name === 'string' ? callParams.name : undefined; + const inputSchema = toolName === undefined ? undefined : product.toolInputSchemaJson(toolName); + if (inputSchema !== undefined) { + const scan = scanXMcpHeaderDeclarations(inputSchema); + if (scan.valid && scan.declarations.length > 0) { + const rejection = validateMcpParamHeaders(scan.declarations, callParams?.arguments, request.headers); + if (rejection !== undefined) { + void product.close().catch(reportError); + reportError(new Error(`Rejected inbound request (${rejection.cell}): ${rejection.message}`)); + return rejectionResponse(rejection, route.message.id); + } + } + } + } + // Era-write at instance binding, then modern-only handler installation — // both before the instance is connected to the per-request transport. setNegotiatedProtocolVersion(server, claimedRevision); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 33f6408e9a..7a2bf55953 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -36,6 +36,7 @@ import { promptArgumentsFromStandardSchema, ProtocolError, ProtocolErrorCode, + scanXMcpHeaderDeclarations, standardSchemaToJsonSchema, UriTemplate, validateAndWarnToolName, @@ -72,6 +73,44 @@ export class McpServer { } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + /** + * Per-tool JSON-converted `inputSchema`, memoized so the SEP-2243 + * registration-time scan and the pre-dispatch validation step share one + * conversion instead of paying it twice per request under the + * per-request-factory `createMcpHandler` model. + */ + private _toolInputSchemaJson: { [name: string]: Record } = {}; + + /** + * The JSON-serialized `inputSchema` of a registered tool, or `undefined` + * when no such tool is registered. Used by the HTTP entry's pre-dispatch + * SEP-2243 `Mcp-Param-*` validation step (which needs the same JSON Schema + * `tools/list` would emit, before dispatch reaches the handler). + * + * @internal + */ + toolInputSchemaJson(name: string): Record | undefined { + const tool = this._registeredTools[name]; + if (tool === undefined || !tool.enabled) return undefined; + if (Object.hasOwn(this._toolInputSchemaJson, name)) return this._toolInputSchemaJson[name]; + if (tool.inputSchema === undefined) return EMPTY_OBJECT_JSON_SCHEMA; + // Lazy path: the memo slot is unset because `registerTool`'s eager + // conversion threw (and was swallowed per its "warn, never throw" + // contract) or `update({paramsSchema})`/rename invalidated it. The + // pre-dispatch SEP-2243 caller must not turn that into a 500 for a + // `tools/call` whose body-authoritative dispatch would otherwise + // succeed — return `undefined` so validation is skipped and the + // conversion failure stays where it always surfaced (`tools/list`). + // A successful re-derive is memoized so the per-request-factory + // `createMcpHandler` model does not re-convert on every call. + try { + const json = standardSchemaToJsonSchema(tool.inputSchema, 'input'); + this._toolInputSchemaJson[name] = json; + return json; + } catch { + return undefined; + } + } constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); @@ -745,6 +784,33 @@ export class McpServer { // Validate tool name according to SEP specification validateAndWarnToolName(name); + // SEP-2243 registration-time declaration-validity check (additive: warn, + // never throw — clients enforce by exclusion, servers by header + // validation; a malformed declaration here should not block local + // development against a stdio client that ignores it). The conversion + // is memoized so the pre-dispatch validation step in `createMcpHandler` + // (and `toolInputSchemaJson()`) does not repeat it for the same tool. + // `standardSchemaToJsonSchema` can throw for schemas it cannot convert + // (e.g. a vendor without `~standard.jsonSchema`); the try/catch keeps + // the "warn, never throw" contract. + if (inputSchema !== undefined) { + try { + const json = standardSchemaToJsonSchema(inputSchema, 'input'); + this._toolInputSchemaJson[name] = json; + const scan = scanXMcpHeaderDeclarations(json); + if (!scan.valid) { + console.warn( + `[mcp-sdk] tool '${name}' carries an invalid x-mcp-header declaration and will be excluded by ` + + `conforming Streamable HTTP clients: ${scan.reason}` + ); + } + } catch { + // Conversion failure: leave the cache slot unset so the lazy + // path in `toolInputSchemaJson()` (and `tools/list`) surfaces + // the failure where it always has. + } + } + // Track current handler for executor regeneration let currentHandler = handler; @@ -763,12 +829,27 @@ export class McpServer { enable: () => registeredTool.update({ enabled: true }), remove: () => registeredTool.update({ name: null }), update: updates => { + // The closure's `name` tracks the CURRENT registry key, not + // the original registration name — renaming reassigns it so + // subsequent paramsSchema/rename invalidations evict the live + // `_toolInputSchemaJson` slot rather than the original. if (updates.name !== undefined && updates.name !== name) { if (typeof updates.name === 'string') { validateAndWarnToolName(updates.name); } delete this._registeredTools[name]; - if (updates.name) this._registeredTools[updates.name] = registeredTool; + delete this._toolInputSchemaJson[name]; + if (updates.name) { + // The TARGET key may already be occupied by another + // tool (rename has no duplicate-name guard) — drop + // its memo too, otherwise `toolInputSchemaJson()` + // returns the displaced tool's converted schema and + // the SEP-2243 pre-dispatch validation runs against + // the wrong schema for this name. + delete this._toolInputSchemaJson[updates.name]; + this._registeredTools[updates.name] = registeredTool; + name = updates.name; + } } if (updates.title !== undefined) registeredTool.title = updates.title; if (updates.description !== undefined) registeredTool.description = updates.description; @@ -777,6 +858,7 @@ export class McpServer { let needsExecutorRegen = false; if (updates.paramsSchema !== undefined) { registeredTool.inputSchema = updates.paramsSchema; + delete this._toolInputSchemaJson[name]; needsExecutorRegen = true; } if (updates.callback !== undefined) { diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts new file mode 100644 index 0000000000..015d63203d --- /dev/null +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -0,0 +1,136 @@ +/** + * SEP-2243 server-side `Mcp-Param-*` validation at the createMcpHandler entry + * (protocol revision 2026-07-28). + * + * Pre-dispatch ladder rung: a `tools/call` whose `Mcp-Param-{Name}` headers + * disagree with the body `arguments` (or are missing for a present body value, + * or carry an invalid Base64 sentinel) is rejected `400` / `-32001` with the + * same `HeaderMismatch` shape the inbound classifier emits for the + * standard-header cross-checks. A `null`/absent body value passes regardless + * of the header (the spec's "server MUST NOT expect" rows). The + * registration-time declaration-validity check warns on invalid declarations. + */ +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + encodeMcpParamValue, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { fromJsonSchema } from '../../src/fromJsonSchema.js'; +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +const MODERN = '2026-07-28'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'param-test', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const REGION_INPUT_SCHEMA = { + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' }, query: { type: 'string' } } +} as const; + +function makeFactory(): () => McpServer { + return () => { + const s = new McpServer({ name: 'param-server', version: '1.0.0' }); + s.registerTool('route', { inputSchema: fromJsonSchema<{ region?: string; query?: string }>(REGION_INPUT_SCHEMA) }, async args => ({ + content: [{ type: 'text', text: `routed ${args.region ?? ''}` }] + })); + return s; + }; +} + +function call(args: Record, paramHeaders: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN, + 'mcp-method': 'tools/call', + ...paramHeaders + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: 'route', arguments: args, _meta: ENVELOPE } + }) + }); +} + +describe('SEP-2243 Mcp-Param-* server validation (createMcpHandler, modern era)', () => { + it('a matching Mcp-Param header passes and the call dispatches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'us-west1', query: 'x' }, { 'Mcp-Param-Region': 'us-west1' })); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('routed us-west1'); + }); + + it('a Base64-sentinel header decodes and matches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'Hello, 世界' }, { 'Mcp-Param-Region': encodeMcpParamValue('Hello, 世界') })); + expect(response.status).toBe(200); + }); + + it('a disagreeing header is rejected 400/-32001 (HeaderMismatch) and reports the rejection', async () => { + const onerror = vi.fn(); + const handler = createMcpHandler(makeFactory(), { onerror }); + const response = await handler.fetch(call({ region: 'us-west1' }, { 'Mcp-Param-Region': 'eu' })); + expect(response.status).toBe(400); + const body = (await response.json()) as { id: unknown; error: { code: number; data?: { mismatch?: { header?: string } } } }; + expect(body.error.code).toBe(-32_001); + expect(body.error.data?.mismatch?.header).toBe('Mcp-Param-Region'); + expect(body.id).toBe(7); + expect(onerror).toHaveBeenCalled(); + }); + + // sep-2243-server-reject-missing-required (globally-untested manifest check). + it('a missing header for a present body value is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'us-west1' })); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number } }; + expect(body.error.code).toBe(-32_001); + }); + + // sep-2243-server-not-expect-null (globally-untested manifest check). + it('a null/absent body value passes regardless of any stray header', async () => { + const handler = createMcpHandler(makeFactory()); + const r1 = await handler.fetch(call({ query: 'x' }, { 'Mcp-Param-Region': 'whatever' })); + const r2 = await handler.fetch(call({ region: null as unknown as string, query: 'x' })); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + }); + + it('an invalid Base64 sentinel is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'Hello' }, { 'Mcp-Param-Region': '=?base64?SGVsbG8?=' })); + expect(response.status).toBe(400); + expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32_001); + }); +}); + +describe('SEP-2243 registerTool declaration-validity check', () => { + it('warns on an invalid x-mcp-header declaration at registration time', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const s = new McpServer({ name: 'warn-server', version: '1.0.0' }); + s.registerTool( + 'bad', + { + inputSchema: fromJsonSchema({ + type: 'object', + properties: { a: { type: 'object', 'x-mcp-header': 'Data' } as Record } + }) + }, + async () => ({ content: [] }) + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("tool 'bad' carries an invalid x-mcp-header")); + warn.mockRestore(); + }); +}); diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index 5c30ea0ade..dc675aff4d 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -48,9 +48,6 @@ client: - auth/scope-retry-limit # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- - # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - - http-custom-headers - - http-invalid-tool-headers # SEP-2106 (JSON Schema $ref handling): no fixture handler for the scenario yet. - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index c5ab22325a..3a178dcf9c 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -18,9 +18,6 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - - http-custom-headers - - http-invalid-tool-headers # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 3b61675c96..1cc2255d61 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -217,6 +217,69 @@ registerScenario('initialize', runBasicClient); registerScenario('tools_call', runToolsCallClient); registerScenario('request-metadata', runRequestMetadataClient); +// ============================================================================ +// SEP-2243 custom-header client scenarios (protocol revision 2026-07-28) +// ============================================================================ + +// The SEP-2243 conformance mocks (http-custom-headers / http-invalid-tool-headers) +// only implement tools/list + tools/call (and a 2025-shaped initialize pinned +// to 2026-07-28, no server/discover) — same connect-time gap as the +// multi-round-trip mock, so use the same withLocalDiscoverResponse fetch shim +// (defined below) to establish the modern era. The runner passes the exact +// tool calls to make via MCP_CONFORMANCE_CONTEXT. + +function readToolCallsContext(): Array<{ name: string; arguments: Record }> { + const raw = process.env.MCP_CONFORMANCE_CONTEXT; + if (!raw) return []; + const parsed = JSON.parse(raw) as { toolCalls?: Array<{ name: string; arguments: Record }> }; + return parsed.toolCalls ?? []; +} + +async function connectModernHeaderClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: withLocalDiscoverResponse({ name: 'test-client', version: '1.0.0' }) + }); + await client.connect(transport); + return client; +} + +// http-custom-headers: the conformance mock advertises test_custom_headers and +// test_custom_headers_null with x-mcp-header annotations. List first (so the +// SDK caches the inputSchema and can mirror), then make the runner-supplied +// calls; the conformance mock validates the Mcp-Param-* headers it receives. +async function runHttpCustomHeadersClient(serverUrl: string): Promise { + const client = await connectModernHeaderClient(serverUrl); + const { tools } = await client.listTools(); + logger.debug('listed tools:', tools.map(t => t.name).join(', ')); + + for (const call of readToolCallsContext()) { + await client.callTool({ name: call.name, arguments: call.arguments }); + } + await client.close(); +} + +// http-invalid-tool-headers: the conformance mock advertises one valid tool +// alongside several constraint-violating ones. listTools() must exclude the +// invalid ones; the fixture then calls every tool that survived — a correct +// SDK leaves only valid_tool, so the mock records SUCCESS for the keep-valid +// check and SUCCESS for every excluded tool not having been called. +async function runHttpInvalidToolHeadersClient(serverUrl: string): Promise { + const client = await connectModernHeaderClient(serverUrl); + const { tools } = await client.listTools(); + logger.debug('post-exclusion tools:', tools.map(t => t.name).join(', ')); + + for (const tool of tools) { + await client.callTool({ name: tool.name, arguments: { region: 'us-west1' } }).catch(error => { + logger.debug(`call ${tool.name} rejected:`, String(error)); + }); + } + await client.close(); +} + +registerScenario('http-custom-headers', runHttpCustomHeadersClient); +registerScenario('http-invalid-tool-headers', runHttpInvalidToolHeadersClient); + // ============================================================================ // Multi-round-trip client scenario (SEP-2322, protocol revision 2026-07-28) // ============================================================================ diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 5429e3be57..85a0934443 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -27,6 +27,7 @@ import { classifyInboundRequest, CLIENT_CAPABILITIES_META_KEY, createMcpHandler, + fromJsonSchema, inputRequired, isInitializeRequest, McpServer, @@ -180,6 +181,27 @@ function createMcpServer() { // ===== TOOLS ===== + // SEP-2243 x-mcp-header tool — arms the http-custom-header-server-validation + // conformance scenario (which skips when no tool with an x-mcp-header + // annotation is found). The schema is hand-written JSON so the annotation + // survives serialization unchanged. + mcpServer.registerTool( + 'test_x_mcp_header', + { + description: 'Tests SEP-2243 Mcp-Param-* server-side validation', + inputSchema: fromJsonSchema<{ region?: string; level?: number }>({ + type: 'object', + properties: { + region: { type: 'string', description: 'mirrored into Mcp-Param-Region', 'x-mcp-header': 'Region' }, + level: { type: 'integer', description: 'non-mirrored argument' } + } + }) + }, + async (args): Promise => ({ + content: [{ type: 'text', text: `region=${args.region ?? ''}` }] + }) + ); + // Simple text tool mcpServer.registerTool( 'test_simple_text', diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 85b6ca5f6d..46e23f86ae 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -53,6 +53,8 @@ export type EntryServerFactory = (ctx?: McpRequestContext) => McpServer | Server export interface RecordedHttpExchange { /** HTTP request method (GET/POST/DELETE). */ method: string; + /** The HTTP request headers as resolved by `new Request(...)` — for raw header assertions (e.g. `Mcp-Param-*`). */ + requestHeaders: Headers; /** The request body text, when one was sent as a string. */ requestBody?: string; /** HTTP response status. */ @@ -155,6 +157,7 @@ export async function wire( const response = await handler.fetch(request); httpLog.push({ method: request.method.toUpperCase(), + requestHeaders: request.headers, ...(typeof init?.body === 'string' && { requestBody: init.body }), status: response.status, contentType: response.headers.get('content-type') ?? '', diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 6fff6c79d6..7f58a901da 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2595,6 +2595,15 @@ export const REQUIREMENTS: Record = { 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.' }, + // SEP-2243 request-metadata headers (protocol revision 2026-07-28) + 'sep-2243:param-header:roundtrip': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#custom-headers-from-tool-parameters', + behavior: + 'A tools/call to a tool whose inputSchema declares an x-mcp-header property carries the corresponding Mcp-Param-{Name} HTTP header on the wire, encoded per the SEP-2243 value-encoding rules, and the call completes successfully against a validating server.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the Mcp-Param-{Name} header is asserted on the arm-recorded HTTP request headers and the encoded value is checked against the SEP-2243 codec.' + }, // Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) 'typescript:mrtr:tools-call:write-once-roundtrip': { source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', diff --git a/test/e2e/scenarios/sep2243.test.ts b/test/e2e/scenarios/sep2243.test.ts new file mode 100644 index 0000000000..60beb935e5 --- /dev/null +++ b/test/e2e/scenarios/sep2243.test.ts @@ -0,0 +1,60 @@ +/** + * SEP-2243 request-metadata headers (protocol revision 2026-07-28). + * + * End-to-end cells for the SEP-2243 header families over the dual-era HTTP + * entry (`createMcpHandler`), exercised on the wire() `entryModern` arm so the + * raw HTTP request headers are observable on the arm-recorded `wired.httpLog`. + */ +import { Client } from '@modelcontextprotocol/client'; +import { encodeMcpParamValue, MCP_PARAM_HEADER_PREFIX } from '@modelcontextprotocol/core'; +import { fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; + +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** + * One tool with a single `x-mcp-header`-declared string parameter. Declared as + * a non-literal const so the JSON-Schema vendor extension key passes excess + * property checking on `fromJsonSchema`'s `JSONSchema.Interface` parameter. + */ +const LOCATE_INPUT_SCHEMA = { + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' } }, + required: ['region'] +}; + +verifies('sep-2243:param-header:roundtrip', async ({ transport }: TestArgs) => { + // The server is built by createMcpHandler per request, so its pre-dispatch + // Mcp-Param-* validation runs against this schema. + const makeServer = () => { + const server = new McpServer({ name: 'e2e-sep2243', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('locate', { inputSchema: fromJsonSchema<{ region: string }>(LOCATE_INPUT_SCHEMA) }, ({ region }) => ({ + content: [{ type: 'text', text: `region=${region}` }] + })); + return server; + }; + const client = new Client({ name: 'sep2243-client', version: '1.0.0' }); + await using wired = await wire(transport, makeServer, client); + + // listTools() auto-aggregates and writes the response cache; callTool + // reads it directly and emits the header on its first attempt (the + // spec's 5-step client algorithm). + await client.listTools(); + const result = await client.callTool({ name: 'locate', arguments: { region: 'us-west1' } }); + + // The tools/call HTTP request carries the Mcp-Param-Region header, + // encoded per the SEP-2243 value-encoding rules (a safe ASCII token + // passes through unchanged). + const callExchange = (wired.httpLog ?? []).find(exchange => exchange.requestBody?.includes('"tools/call"')); + expect(callExchange).toBeDefined(); + const headerValue = callExchange!.requestHeaders.get(`${MCP_PARAM_HEADER_PREFIX}Region`); + expect(headerValue).toBe(encodeMcpParamValue('us-west1')); + expect(headerValue).toBe('us-west1'); + + // The call succeeded against the validating server (header agreed with + // the body argument, so no -32001 HeaderMismatch on the wire). + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: 'text', text: 'region=us-west1' }]); +}); From faf94f834f8dd2bc24e92eb404120839f5958174 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:28:16 +0100 Subject: [PATCH 37/37] =?UTF-8?q?feat:=20SEP-2243=20standard=20half=20?= =?UTF-8?q?=E2=80=94=20std-header=20server=20validation;=20http-header-val?= =?UTF-8?q?idation=2013/0=20(#2328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/sep-2243-std-header-server.md | 10 + docs/migration.md | 26 ++- examples/json-response/client.ts | 3 +- packages/client/src/client/streamableHttp.ts | 7 +- .../core/src/shared/inboundClassification.ts | 142 ++++++++++++- .../shared/standardHeaderValidation.test.ts | 191 ++++++++++++++++++ .../server/src/server/createMcpHandler.ts | 28 ++- .../test/server/createMcpHandler.test.ts | 22 +- .../createMcpHandlerCapabilityGate.test.ts | 7 +- .../server/createMcpHandlerListen.test.ts | 12 +- .../test/server/mcpParamValidation.test.ts | 1 + .../test/server/stdHeaderValidation.test.ts | 169 ++++++++++++++++ .../expected-failures.2026-07-28.yaml | 7 - test/conformance/expected-failures.yaml | 5 - test/e2e/requirements.ts | 8 + .../scenarios/hosting-entry-session.test.ts | 7 +- test/e2e/scenarios/sep2243.test.ts | 33 ++- test/e2e/scenarios/subscriptions.test.ts | 12 +- .../test/server/createMcpHandler.test.ts | 6 +- 19 files changed, 655 insertions(+), 41 deletions(-) create mode 100644 .changeset/sep-2243-std-header-server.md create mode 100644 packages/core/test/shared/standardHeaderValidation.test.ts create mode 100644 packages/server/test/server/stdHeaderValidation.test.ts diff --git a/.changeset/sep-2243-std-header-server.md b/.changeset/sep-2243-std-header-server.md new file mode 100644 index 0000000000..1ffbc57341 --- /dev/null +++ b/.changeset/sep-2243-std-header-server.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +SEP-2243 standard-header server-side validation (protocol revision 2026-07-28). On the modern (2026-07-28) serving path, `createMcpHandler` now enforces the required `Mcp-Method` and `Mcp-Name` standard request headers in addition to the existing `MCP-Protocol-Version` and `Mcp-Method` cross-checks: a modern request without an `Mcp-Method` header, a `tools/call` / `prompts/get` / `resources/read` request without an `Mcp-Name` header, an `Mcp-Name` header carrying an invalid `=?base64?…?=` sentinel, and an `Mcp-Name` header whose (decoded) value disagrees with the body's `params.name` / `params.uri` are all rejected with `400 Bad Request` and JSON-RPC `-32001` (`HeaderMismatch`). The 2025-era serving paths are unchanged. + +New public surface: + +- `@modelcontextprotocol/core`: `validateStandardRequestHeaders` (function), `MCP_NAME_HEADER_SOURCE` (const), the `mcpNameHeader` field on `InboundHttpRequest`, and the `'standard-header-validation'` member of `InboundValidationRung` (with `client-capabilities` / `param-header-validation` renumbered). diff --git a/docs/migration.md b/docs/migration.md index 5c5c63a8bb..c78eaf9fc0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1056,16 +1056,16 @@ versionNegotiation: { } ``` -`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an -already-constructed instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). +`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an already-constructed +instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). -Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass -in a request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. +Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass in a +request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize` -handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and -other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that -protocol revision. +handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and other +long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol +revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` @@ -1140,6 +1140,10 @@ skips the reserved standard/auth header names so a per-request header cannot ove `Client.listTools()` (and the client's internal `tools/list` cache) exclude tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning naming the tool and the reason. Browser clients skip mirroring (dynamically named headers cannot be statically allow-listed for credentialed CORS); calling an `x-mcp-header` tool with a non-null designated argument from a browser against a conforming SEP-2243 server is therefore a known limitation. +On the modern path, `createMcpHandler` also validates the SEP-2243 **standard** request-metadata headers against the body and rejects with the same `400` / `-32001` (`HeaderMismatch`) when the `MCP-Protocol-Version` or `Mcp-Method` header disagrees with the body, when the +required `Mcp-Method` header is absent, when the required `Mcp-Name` header is absent on a `tools/call` / `prompts/get` / `resources/read` request, and when the (Base64-sentinel-decoded) `Mcp-Name` value disagrees with `params.name` / `params.uri`. These checks only fire on the +modern (2026-07-28) serving path — 2025-era traffic is unchanged — and a hand-built modern HTTP request must carry the `Mcp-Method` (and where applicable `Mcp-Name`) header; the SDK client already sends them. + ### Serving the 2026-07-28 draft revision on stdio: `serveStdio` The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to that @@ -1213,9 +1217,11 @@ const handler = createMcpHandler(() => buildServer()); handler.notify.toolsChanged(); ``` -**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers -fire on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if you still want events); change notifications dispatch to the existing `setNotificationHandler` -registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead. +**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers fire +on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves +once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if +you still want events); change notifications dispatch to the existing `setNotificationHandler` registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter +instead. ### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts index 147d91f097..d047a0a59d 100644 --- a/examples/json-response/client.ts +++ b/examples/json-response/client.ts @@ -18,7 +18,8 @@ runClient('json-response', async () => { headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', - 'mcp-protocol-version': '2026-07-28' + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': 'tools/list' }, body: JSON.stringify({ jsonrpc: '2.0', diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 440422c130..9c7495a57c 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -332,10 +332,9 @@ export class StreamableHTTPClientTransport implements Transport { // non-ASCII name/URI (or one with leading/trailing whitespace, // control characters, or CR/LF) cannot make `Headers.set()` throw a // TypeError or silently normalize to a value that differs from the - // body. The spec's value-encoding rules apply to `Mcp-Name`; this - // SDK's server does not yet cross-check `Mcp-Name` against the body - // (tracked in expected-failures.yaml) — when it does it will decode - // the sentinel before comparison. + // body. The spec's value-encoding rules apply to `Mcp-Name`; the SDK + // server's `validateStandardRequestHeaders` decodes the sentinel via + // `decodeMcpParamValue` before the `Mcp-Name` ↔ body cross-check. const params = message.params as { name?: unknown; uri?: unknown } | undefined; const nameHeader = message.method === 'resources/read' diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 800d65941e..4f4be395cb 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -69,6 +69,12 @@ import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors. import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js'; import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '../types/types.js'; import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js'; +// Value encoding is shared between the standard `Mcp-Name` header and the +// custom `Mcp-Param-*` headers; the codec module already imports the +// `HeaderMismatch` constant and rejection type from here, so this is a benign +// two-module cycle (both sides only consume the other's exports inside +// function bodies, never at module-evaluation time). +import { decodeMcpParamValue } from './mcpParamHeaders.js'; import { isModernProtocolVersion } from './protocolEras.js'; /* ------------------------------------------------------------------------ * @@ -88,6 +94,8 @@ export interface InboundHttpRequest { protocolVersionHeader?: string; /** The value of the `Mcp-Method` header, when present. */ mcpMethodHeader?: string; + /** The value of the `Mcp-Name` header, when present. */ + mcpNameHeader?: string; /** The parsed JSON request body (`undefined` for body-less methods). */ body?: unknown; } @@ -161,6 +169,7 @@ export type InboundValidationRung = | 'envelope' | 'method-registry' | 'request-params' + | 'standard-header-validation' | 'client-capabilities' | 'param-header-validation'; @@ -305,9 +314,25 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor rationale: 'Per-method params validation; emitted in-band by the dispatch layer (HTTP 200), never via the ladder status table.' }, { - rung: 'client-capabilities', + rung: 'standard-header-validation', order: 7, evaluatedAt: 'pre-dispatch', + codes: [HEADER_MISMATCH_ERROR_CODE], + conformance: ['http-header-validation'], + rationale: + 'SEP-2243 standard `Mcp-Method` / `Mcp-Name` headers — presence, sentinel decoding, and `Mcp-Name` ↔ body cross-check ' + + '— are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before ' + + 'dispatch. The classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge ' + + '`era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half. Evaluated before the ' + + 'capability gate, the factory call, and the `Mcp-Param-*` rung so a request that fails several rungs is answered by ' + + 'the standard-header rung first. The documented order (after method-registry 5 and request-params 6) is NOT the ' + + 'observed precedence: serveModern evaluates this rung immediately after the supported-revision gate, so a request ' + + 'that also fails a dispatch rung is answered here before the dispatch rungs (5–6) are consulted.' + }, + { + rung: 'client-capabilities', + order: 8, + evaluatedAt: 'pre-dispatch', codes: [ProtocolErrorCode.MissingRequiredClientCapability], conformance: ['server-stateless'], rationale: @@ -320,7 +345,7 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor }, { rung: 'param-header-validation', - order: 8, + order: 9, evaluatedAt: 'pre-dispatch', codes: [HEADER_MISMATCH_ERROR_CODE], conformance: ['http-custom-header-server-validation'], @@ -396,9 +421,14 @@ function rejection( }; } -function crossCheckMismatch(cell: string, header: string, body: string): InboundLadderRejection { +function crossCheckMismatch( + cell: string, + header: string, + body: string, + rung: InboundValidationRung = 'era-classification' +): InboundLadderRejection { return rejection( - 'era-classification', + rung, cell, 400, new ProtocolError(HEADER_MISMATCH_ERROR_CODE, `Bad Request: the request headers and body disagree: ${body}`, { @@ -408,6 +438,110 @@ function crossCheckMismatch(cell: string, header: string, body: string): Inbound ); } +/** + * The methods whose body carries a `params.name` / `params.uri` value the + * `Mcp-Name` header must mirror, and which body field supplies it (SEP-2243 + * § Standard Request Headers, `Required For` column). + */ +export const MCP_NAME_HEADER_SOURCE: Readonly> = { + 'tools/call': 'name', + 'prompts/get': 'name', + 'resources/read': 'uri' +}; + +/** + * SEP-2243 standard-header server-side validation, evaluated by the HTTP + * entry on a modern-classified request immediately after + * {@linkcode classifyInboundRequest} returns a modern route. + * + * Returns the `-32001` (`HeaderMismatch`) ladder rejection (HTTP `400`, + * `standard-header-validation` rung — the same shape + * {@linkcode classifyInboundRequest} already emits on the edge + * `era-classification` rung for the `MCP-Protocol-Version` and + * `Mcp-Method` *mismatch* cells) when: + * + * - the required `Mcp-Method` header is absent; + * - the required `Mcp-Name` header is absent on a `tools/call`, + * `prompts/get`, or `resources/read` request whose body carries the + * `params.name` / `params.uri` value the header mirrors; + * - the `Mcp-Name` header carries an invalid `=?base64?…?=` sentinel; or + * - the (decoded) `Mcp-Name` value disagrees with the body's + * `params.name` / `params.uri`. + * + * Returns `undefined` (pass) for notifications (the spec table reads + * "All requests"), for methods that have no `Mcp-Name` source, and when the + * headers agree with the body. Never enforced on legacy traffic — the entry + * only calls this on a modern route. + * + * Kept separate from {@linkcode classifyInboundRequest} so that a body-only + * call to the classifier (no headers passed) keeps routing a modern request + * unchanged: the classifier remains a pure body-primary router, and this + * function is the presence/`Mcp-Name` half of the standard-header rung the + * entry layers on top. + */ +export function validateStandardRequestHeaders(request: InboundHttpRequest, route: InboundModernRoute): InboundLadderRejection | undefined { + if (route.messageKind !== 'request') { + return undefined; + } + const method = route.message.method; + + if (request.mcpMethodHeader === undefined) { + return crossCheckMismatch( + 'method-header-missing', + '(missing)', + `the body names method ${method} but the required Mcp-Method header is absent`, + 'standard-header-validation' + ); + } + + // `method` is the JSON-RPC method string from the body — peer-controlled, + // so guard the plain-object lookup against `Object.prototype` collisions + // (`constructor`, `toString`, …) the same way the client-capability table + // lookup does. + const sourceField = Object.hasOwn(MCP_NAME_HEADER_SOURCE, method) ? MCP_NAME_HEADER_SOURCE[method] : undefined; + if (sourceField === undefined) { + return undefined; + } + const params = route.message.params as Record | undefined; + const sourceValue = params?.[sourceField]; + const bodyValue = typeof sourceValue === 'string' ? sourceValue : undefined; + + if (request.mcpNameHeader === undefined) { + // The header is required for these methods whenever the body carries + // the source value. A body without `params.name`/`params.uri` is a + // params-validation failure further down the ladder; this rung only + // answers the missing-header case it can observe. + if (bodyValue === undefined) { + return undefined; + } + return crossCheckMismatch( + 'name-header-missing', + '(missing)', + `the body carries params.${sourceField}="${bodyValue}" but the required Mcp-Name header is absent`, + 'standard-header-validation' + ); + } + + const decoded = decodeMcpParamValue(request.mcpNameHeader); + if (decoded === undefined) { + return crossCheckMismatch( + 'name-header-invalid-encoding', + request.mcpNameHeader, + 'the Mcp-Name header carries an invalid Base64 sentinel value', + 'standard-header-validation' + ); + } + if (bodyValue !== undefined && decoded !== bodyValue) { + return crossCheckMismatch( + 'name-header-mismatch', + request.mcpNameHeader, + `the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"`, + 'standard-header-validation' + ); + } + return undefined; +} + function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/core/test/shared/standardHeaderValidation.test.ts b/packages/core/test/shared/standardHeaderValidation.test.ts new file mode 100644 index 0000000000..97baadb407 --- /dev/null +++ b/packages/core/test/shared/standardHeaderValidation.test.ts @@ -0,0 +1,191 @@ +/** + * SEP-2243 standard-header server-side validation + * (`validateStandardRequestHeaders`). + * + * Evaluated by the HTTP entry on a modern-classified request immediately + * after `classifyInboundRequest` returns a modern route: rejects `400` / + * `-32001` (`HeaderMismatch`) when the required `Mcp-Method` header is + * absent, when the required `Mcp-Name` header is absent on a `tools/call` / + * `prompts/get` / `resources/read` request, when the `Mcp-Name` header + * carries an invalid Base64 sentinel, and when its (decoded) value disagrees + * with the body's `params.name` / `params.uri`. Never enforced on + * notifications or on methods without an `Mcp-Name` source. + * + * The classifier itself is left unchanged by these rungs (it stays a + * body-primary router that passes a modern request through when no headers + * are supplied) — this function is the presence/`Mcp-Name` half of the + * standard-header rung the entry layers on top, so the existing + * `inboundClassification` and cell-sheet tests stay byte-untouched. + */ +import { describe, expect, test } from 'vitest'; + +import type { InboundHttpRequest, InboundLadderRejection, InboundModernRoute } from '../../src/shared/inboundClassification.js'; +import { classifyInboundRequest, MCP_NAME_HEADER_SOURCE, validateStandardRequestHeaders } from '../../src/shared/inboundClassification.js'; +import { encodeMcpParamValue } from '../../src/shared/mcpParamHeaders.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; + +const MODERN = '2026-07-28'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'std-header-test', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function modernPost( + method: string, + params: Record, + headers: { mcpMethod?: string; mcpName?: string } = {} +): { request: InboundHttpRequest; route: InboundModernRoute } { + const request: InboundHttpRequest = { + httpMethod: 'POST', + protocolVersionHeader: MODERN, + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }), + ...(headers.mcpName !== undefined && { mcpNameHeader: headers.mcpName }), + body: { jsonrpc: '2.0', id: 1, method, params: { ...params, _meta: ENVELOPE } } + }; + const outcome = classifyInboundRequest(request); + if (outcome.kind !== 'modern') { + throw new Error(`expected a modern route, got ${outcome.kind}`); + } + return { request, route: outcome }; +} + +function expectRejection(result: InboundLadderRejection | undefined, cell: string): void { + expect(result).toBeDefined(); + expect(result?.kind).toBe('reject'); + expect(result?.cell).toBe(cell); + expect(result?.rung).toBe('standard-header-validation'); + expect(result?.httpStatus).toBe(400); + expect(result?.code).toBe(-32_001); + expect(result?.settled).toBe(true); +} + +describe('SEP-2243 standard-header validation (Mcp-Method presence)', () => { + test('a modern request without an Mcp-Method header is rejected (method-header-missing)', () => { + const { request, route } = modernPost('tools/list', {}); + expectRejection(validateStandardRequestHeaders(request, route), 'method-header-missing'); + }); + + test('a present Mcp-Method header passes for a method with no Mcp-Name source', () => { + const { request, route } = modernPost('tools/list', {}, { mcpMethod: 'tools/list' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('the Mcp-Method mismatch cell stays inside classifyInboundRequest (precedence over presence)', () => { + // The mismatch is answered by the classifier itself; this function + // never sees a route for that input. Asserted here so the + // standard-header rung's two halves stay observably ordered. + const inbound: InboundHttpRequest = { + httpMethod: 'POST', + protocolVersionHeader: MODERN, + mcpMethodHeader: 'prompts/list', + body: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: ENVELOPE } } + }; + const outcome = classifyInboundRequest(inbound); + expect(outcome.kind).toBe('reject'); + expect((outcome as InboundLadderRejection).cell).toBe('method-header-mismatch'); + }); + + test('notifications are never enforced', () => { + const route: InboundModernRoute = { + kind: 'modern', + messageKind: 'notification', + message: { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, + classification: { era: 'modern', revision: MODERN } + }; + expect(validateStandardRequestHeaders({ httpMethod: 'POST' }, route)).toBeUndefined(); + }); +}); + +describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check)', () => { + test('a tools/call without an Mcp-Name header is rejected (name-header-missing)', () => { + const { request, route } = modernPost('tools/call', { name: 'echo', arguments: {} }, { mcpMethod: 'tools/call' }); + expectRejection(validateStandardRequestHeaders(request, route), 'name-header-missing'); + }); + + test('a resources/read without an Mcp-Name header is rejected and names params.uri', () => { + const { request, route } = modernPost('resources/read', { uri: 'file:///a' }, { mcpMethod: 'resources/read' }); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-missing'); + expect(result?.message).toContain('params.uri'); + }); + + test('a tools/call whose body has no params.name passes the Mcp-Name presence rung', () => { + // The missing `params.name` is a request-params failure further down + // the ladder; this rung only answers what it can observe. + const { request, route } = modernPost('tools/call', { arguments: {} }, { mcpMethod: 'tools/call' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('an Mcp-Name header disagreeing with params.name is rejected (name-header-mismatch)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: 'wrong_tool_name' } + ); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-mismatch'); + expect((result?.data as { mismatch?: { header?: string } })?.mismatch?.header).toBe('wrong_tool_name'); + }); + + test('a Base64-sentinel Mcp-Name decodes before comparison (matching)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'Hello, 世界', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: encodeMcpParamValue('Hello, 世界') } + ); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('a Base64-sentinel Mcp-Name decodes before comparison (mismatch names the decoded value)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: encodeMcpParamValue('not-echo') } + ); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-mismatch'); + expect(result?.message).toContain('"not-echo"'); + }); + + test('an invalid Base64 sentinel in Mcp-Name is rejected (name-header-invalid-encoding)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: '=?base64?SGVs!!!bG8=?=' } + ); + expectRejection(validateStandardRequestHeaders(request, route), 'name-header-invalid-encoding'); + }); + + test('a matching Mcp-Name on a prompts/get passes', () => { + const { request, route } = modernPost('prompts/get', { name: 'greeting' }, { mcpMethod: 'prompts/get', mcpName: 'greeting' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('a matching Mcp-Name on a resources/read compares against params.uri', () => { + const uri = 'file:///projects/app/config.json'; + const { request, route } = modernPost('resources/read', { uri }, { mcpMethod: 'resources/read', mcpName: uri }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('the Mcp-Name source map covers exactly the spec table', () => { + expect(MCP_NAME_HEADER_SOURCE).toEqual({ 'tools/call': 'name', 'prompts/get': 'name', 'resources/read': 'uri' }); + }); + + test('a method colliding with Object.prototype members is treated as off-table (passes through to dispatch)', () => { + // `constructor` would return Object.prototype.constructor on a bare + // lookup; the Object.hasOwn guard keeps the early-return firing. + const { request, route } = modernPost('constructor', {}, { mcpMethod: 'constructor', mcpName: '=?base64?!!?=' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); +}); + +describe('classifyInboundRequest is unchanged by the standard-header presence rung', () => { + test('a body-only modern request (no headers passed) still routes modern', () => { + const outcome = classifyInboundRequest({ + httpMethod: 'POST', + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: {}, _meta: ENVELOPE } } + }); + expect(outcome.kind).toBe('modern'); + }); +}); diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 5d02871a6e..4f51c2ccc3 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -52,7 +52,8 @@ import { setNegotiatedProtocolVersion, SUPPORTED_MODERN_PROTOCOL_VERSIONS, UnsupportedProtocolVersionError, - validateMcpParamHeaders + validateMcpParamHeaders, + validateStandardRequestHeaders } from '@modelcontextprotocol/core'; import { invoke } from './invoke.js'; @@ -471,6 +472,7 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno httpMethod, protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined, ...(body !== undefined && { body }) }); return { step: 'classified', outcome, body, parsedBody, forwardRequest }; @@ -644,6 +646,30 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return jsonRpcErrorResponse(400, error.code, error.message, error.data, echoableRequestId(route.message)); } + // SEP-2243 standard-header presence and `Mcp-Name` cross-check + // (`standard-header-validation` rung; the `MCP-Protocol-Version` and + // `Mcp-Method` *mismatch* cells are already answered inside + // `classifyInboundRequest` on the edge `era-classification` rung). + // Evaluated after the supported-revision + // gate so an envelope naming a revision this endpoint does not serve + // is still answered with `-32004` (the supported list is the more + // useful answer to a client speaking the wrong revision); evaluated + // before the capability gate, the factory call, and the + // `Mcp-Param-*` rung so a request that fails several rungs is + // answered by the standard-header rung first. + const stdHeaderRejection = validateStandardRequestHeaders( + { + httpMethod: request.method, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined + }, + route + ); + if (stdHeaderRejection !== undefined) { + reportError(new Error(`Rejected inbound request (${stdHeaderRejection.cell}): ${stdHeaderRejection.message}`)); + return rejectionResponse(stdHeaderRejection, echoableRequestId(route.message)); + } + const meta = route.messageKind === 'request' ? requestMetaOf(route.message.params) : undefined; const declaredClientCapabilities = meta?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 0a069376ac..ee5e8dcf9e 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -41,12 +41,30 @@ function modernToolsCall(name: string, args: Record, envelope: }; } +/** + * The SEP-2243 standard headers a conformant client derives from the body it + * sends. Only emitted for a body carrying a modern envelope claim, so legacy + * test cells stay byte-untouched; spread before any explicit `headers` so a + * caller that needs to test a stripped or disagreeing header can override. + */ +function bodyDerivedStandardHeaders(body: unknown): Record { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return {}; + const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record } }; + if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {}; + const out: Record = {}; + if (typeof b.method === 'string') out['mcp-method'] = b.method; + const name = b.method === 'resources/read' ? b.params.uri : b.params.name; + if (typeof name === 'string') out['mcp-name'] = name; + return out; +} + function postRequest(body: unknown, headers: Record = {}): Request { return new Request('http://localhost/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', + ...bodyDerivedStandardHeaders(body), ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) @@ -782,6 +800,7 @@ describe('createMcpHandler — handler faces', () => { const parsed = modernToolsCall('echo', { text: 'pre-parsed' }); const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); await handler.node(req, res, parsed); expect(res.statusCode).toBe(200); expect(await body()).toContain('pre-parsed'); @@ -939,7 +958,8 @@ function nodeRequestResponse(body: unknown): { headers: { host: 'localhost:3000', 'content-type': 'application/json', - accept: 'application/json, text/event-stream' + accept: 'application/json, text/event-stream', + ...bodyDerivedStandardHeaders(body) } as Record }); diff --git a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts index 3decb71b60..9d40e8e66f 100644 --- a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts +++ b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts @@ -33,7 +33,12 @@ const envelope = (clientCapabilities: ClientCapabilities) => ({ function postEcho(clientCapabilities: ClientCapabilities): Request { return new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/call', + 'mcp-name': 'echo' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 7, diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 479e17b905..980e7e7578 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -27,7 +27,11 @@ const ENVELOPE = { function listenRequest(id: string | number, filter: Record): Request { return new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id, @@ -165,7 +169,11 @@ describe('createMcpHandler — subscriptions/listen', () => { const response = await handler.fetch( new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'subscriptions/listen', params: { _meta: ENVELOPE } }) }) ); diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts index 015d63203d..8c49d92b49 100644 --- a/packages/server/test/server/mcpParamValidation.test.ts +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -52,6 +52,7 @@ function call(args: Record, paramHeaders: Record McpServer { + return () => { + const s = new McpServer({ name: 'std-header-server', version: '1.0.0' }); + s.registerTool('echo', { inputSchema: z.object({ text: z.string().optional() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: text ?? 'ok' }] + })); + return s; + }; +} + +function modernRequest(method: string, params: Record, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN, + ...headers + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 5, method, params: { ...params, _meta: ENVELOPE } }) + }); +} + +async function expectHeaderMismatch(response: Response): Promise<{ code: number; message: string }> { + expect(response.status).toBe(400); + const body = (await response.json()) as { id: unknown; error: { code: number; message: string } }; + expect(body.id).toBe(5); + expect(body.error.code).toBe(-32_001); + return body.error; +} + +describe('SEP-2243 standard-header validation (createMcpHandler, modern era)', () => { + it('a fully conformant tools/call passes and dispatches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } }, { 'mcp-method': 'tools/call', 'mcp-name': 'echo' }) + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hi'); + }); + + it('a missing Mcp-Method header is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch(await handler.fetch(modernRequest('tools/list', {}))); + expect(error.message).toContain('Mcp-Method header is absent'); + }); + + it('a missing Mcp-Name header on tools/call is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch( + await handler.fetch(modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call' })) + ); + expect(error.message).toContain('Mcp-Name header is absent'); + }); + + it('an Mcp-Name header disagreeing with params.name is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch( + await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call', 'mcp-name': 'wrong' }) + ) + ); + expect(error.message).toContain('Mcp-Name header names "wrong"'); + }); + + it('Mcp-Name accepts an OWS-padded value (RFC 9110 §5.5; Fetch Headers normalises)', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call', 'mcp-name': ' echo ' }) + ); + expect(response.status).toBe(200); + }); + + it('Mcp-Name decodes a Base64 sentinel before comparison', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': encodeMcpParamValue('echo') } + ) + ); + // `encodeMcpParamValue('echo')` is plain ASCII, so the sentinel is not + // applied; assert the explicit-sentinel case below instead. + expect(response.status).toBe(200); + const sentinel = await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': `=?base64?${Buffer.from('echo').toString('base64')}?=` } + ) + ); + expect(sentinel.status).toBe(200); + }); + + it('an invalid Mcp-Name Base64 sentinel is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + await expectHeaderMismatch( + await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': '=?base64?SGVsbG8?=' } + ) + ) + ); + }); + + it('Mcp-Name is not required for methods outside its source map', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(modernRequest('tools/list', {}, { 'mcp-method': 'tools/list' })); + expect(response.status).toBe(200); + }); +}); + +describe('SEP-2243 standard-header validation is era-gated', () => { + it('legacy traffic is byte-untouched: a 2025-era initialize without standard headers still serves', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 5, + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'c', version: '1' }, capabilities: {} } + }) + }) + ); + // The default 'stateless' legacy posture answers initialize. + expect(response.status).toBe(200); + }); +}); diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index dc675aff4d..3a5194f209 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -73,10 +73,3 @@ server: # (WARNING-only; the expected-failures evaluator counts WARNINGs as # failures). Same failure as in the 2025 baseline. - sep-2164-resource-not-found - - # --- Draft scenarios (same failures and reasons as the `--suite draft` leg) --- - # SEP-2243 (HTTP header standardization): the reject cells the SDK does - # answer now use -32001 (HeaderMismatch), but missing-header enforcement - # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, - # so those reject cells are still accepted with 200. - - http-header-validation diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 3a178dcf9c..9fed2dee25 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -46,11 +46,6 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) --- - # SEP-2243 (HTTP header standardization): the reject cells the SDK does - # answer now use -32001 (HeaderMismatch), but missing-header enforcement - # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, - # so those reject cells are still accepted with 200. - - http-header-validation # WARNING-only entry: the scenario emits no FAILURE checks, only a SHOULD-level # WARNING, but the expected-failures evaluator counts WARNINGs as failures. # SEP-2164: server returns -32002 without the requested URI in error.data. diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 7f58a901da..5789be6c73 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2604,6 +2604,14 @@ export const REQUIREMENTS: Record = { addedInSpecVersion: '2026-07-28', note: 'Runs on the entryModern arm; the Mcp-Param-{Name} header is asserted on the arm-recorded HTTP request headers and the encoded value is checked against the SEP-2243 codec.' }, + 'sep-2243:std-header:mismatch-rejected': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#standard-request-headers', + behavior: + 'A 2026-07-28 request whose Mcp-Method header disagrees with the JSON-RPC method in the body is rejected by the createMcpHandler entry with HTTP 400 carrying a JSON-RPC error with the SEP-2243 HeaderMismatch code.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the body POSTs a raw envelope-carrying tools/call with an Mcp-Method: tools/list header through wired.fetch and asserts the 400 status and the HeaderMismatch error code on the response bytes.' + }, // Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) 'typescript:mrtr:tools-call:write-once-roundtrip': { source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts index a41a1b8bbb..2a09b95d42 100644 --- a/test/e2e/scenarios/hosting-entry-session.test.ts +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -169,7 +169,12 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async () => { const exchangesBeforeModernProbe = legacyExchanges.length; const modernProbe = await fetchViaRouter(url, { method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/call', + 'mcp-name': 'greet' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 100, diff --git a/test/e2e/scenarios/sep2243.test.ts b/test/e2e/scenarios/sep2243.test.ts index 60beb935e5..8d3ab17f92 100644 --- a/test/e2e/scenarios/sep2243.test.ts +++ b/test/e2e/scenarios/sep2243.test.ts @@ -10,7 +10,7 @@ import { encodeMcpParamValue, MCP_PARAM_HEADER_PREFIX } from '@modelcontextproto import { fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; import { expect } from 'vitest'; -import { wire } from '../helpers/index.js'; +import { modernEnvelopeMeta, wire } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; @@ -58,3 +58,34 @@ verifies('sep-2243:param-header:roundtrip', async ({ transport }: TestArgs) => { expect(result.isError).toBeFalsy(); expect(result.content).toEqual([{ type: 'text', text: 'region=us-west1' }]); }); + +verifies('sep-2243:std-header:mismatch-rejected', async ({ transport }: TestArgs) => { + const makeServer = () => new McpServer({ name: 'e2e-sep2243-std', version: '1.0.0' }, { capabilities: { tools: {} } }); + const client = new Client({ name: 'sep2243-std-client', version: '1.0.0' }); + await using wired = await wire(transport, makeServer, client); + + // Raw POST through the harness-hosted entry: the body is a valid + // envelope-carrying tools/call, but the Mcp-Method header names + // tools/list. The era-classification rung answers the disagreement + // before any factory instance is constructed. + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/list' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'locate', arguments: {}, _meta: modernEnvelopeMeta({ name: 'sep2243-std-client', version: '1.0.0' }) } + }) + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; message: string } }; + // -32001 is the SEP-2243 HeaderMismatch code at this branch's spec pin. + expect(body.error.code).toBe(-32_001); + expect(body.error.message).toMatch(/Mcp-Method/); +}); diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts index bbca6105d5..cd9fea8981 100644 --- a/test/e2e/scenarios/subscriptions.test.ts +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -42,7 +42,11 @@ verifies('subscriptions:listen:ack-first-stamped', async () => { const response = await handler.fetch( new Request('http://in-process/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 'sub-1', @@ -140,7 +144,11 @@ verifies('subscriptions:listen:capacity-guard', async () => { handler.fetch( new Request('http://in-process/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id, diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index ae2a30aac8..6ce066ed1e 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -169,7 +169,11 @@ describe('createMcpHandler over HTTP — subscriptions/listen honored filter', ( const response = await fetch(new URL('/mcp', baseUrl), { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 'sub-1',