From e20ffcb17d5e451ad03bee7b1663256f7f9bf939 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 30 Jun 2026 17:13:40 +0000 Subject: [PATCH] test(conformance): bump referee to 0.2.0-alpha.9; arm SEP-2575 diagnostic fixtures; fix post-dispatch -32021 HTTP status conformance 0.2.0-alpha.8 fails checks whose prerequisite is missing instead of silently skipping them (conformance#372). Adopting that line surfaced two gaps: - the conformance everythingServer never registered the three diagnostic tools the server-stateless scenario hardcodes; test_missing_capability, test_streaming_elicitation and test_logging_tool are now armed. - arming test_missing_capability made a real defect reachable: a MissingRequiredClientCapabilityError (-32021) produced after dispatch (the input_required capability gate) surfaced in-band on HTTP 200, while the spec mandates 400 for this error with no origin condition. The in-band status policy now carries that one code-keyed exception (httpStatusForErrorCode + the per-request transport), applied only while the response is uncommitted: an exchange that already streamed, or one hosted with responseMode 'sse' (which opens its stream at dispatch end), keeps its committed 200. Every other handler-produced code - including a handler relaying a downstream peer's -32020/-32022 - keeps the origin-keyed in-band 200. alpha.9 also contains conformance#376 (the server-stateless requiredCapabilities assertion corrected from an array to the schema's ClientCapabilities object), so server-stateless passes outright and is NOT baselined. The expected-failures baselines now carry only the tasks-* scenarios (the server SDK does not implement the SEP-2663 tasks extension); the 2026-07-28 baseline is empty in both directions. All six legs exit 0 with zero stale entries: client:all 438/0, client:2026 374/0, server 42/0, server:draft 85/0, server:extensions 140 passed / 30 expected (all tasks-*), server:2026 114/0. --- .changeset/post-dispatch-32021-http-400.md | 6 ++ docs/migration/support-2026-07-28.md | 12 ++++ .../src/shared/inboundClassification.ts | 23 ++++++-- .../test/shared/errorHttpStatusMatrix.test.ts | 41 +++++++++---- .../shared/inboundLadderCellSheet.test.ts | 45 ++------------- .../server/src/server/createMcpHandler.ts | 8 ++- .../server/src/server/perRequestTransport.ts | 33 ++++++++--- .../test/server/perRequestTransport.test.ts | 38 +++++++++++++ pnpm-lock.yaml | 10 ++-- .../expected-failures.2026-07-28.yaml | 7 ++- test/conformance/expected-failures.yaml | 4 +- test/conformance/package.json | 2 +- test/conformance/src/everythingServer.ts | 57 +++++++++++++++++++ 13 files changed, 211 insertions(+), 75 deletions(-) create mode 100644 .changeset/post-dispatch-32021-http-400.md diff --git a/.changeset/post-dispatch-32021-http-400.md b/.changeset/post-dispatch-32021-http-400.md new file mode 100644 index 0000000000..5f51fe0d62 --- /dev/null +++ b/.changeset/post-dispatch-32021-http-400.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/core-internal': patch +--- + +Return HTTP 400 for a `MissingRequiredClientCapabilityError` (`-32021`) produced after dispatch. The spec mandates `400 Bad Request` for this error with no condition on where it arose, but only the pre-dispatch capability gate honored that; the post-handler emission — the `input_required` gate rejecting an embedded request whose required capability the caller did not declare — surfaced in-band on HTTP 200. The JSON-RPC error body is unchanged, every other error code (including a handler relaying a downstream peer's `-32020`/`-32022`) keeps the origin-keyed in-band behavior, and the mapping only applies while the response is uncommitted: an exchange that already streamed — or one hosted with `responseMode: 'sse'`, which opens its stream at dispatch end — keeps its committed 200 and carries the error in-stream. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 57b9ff2d14..46bbdc941c 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -157,6 +157,18 @@ fetch-shaped handler. > `toNodeHandler(handler)` and add the `@modelcontextprotocol/node` import. > `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from > `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`. +> +> Also: a `MissingRequiredClientCapabilityError` (`-32021`) produced **after** dispatch +> — the `input_required` gate refusing an embedded request whose capability the caller +> did not declare — now answers HTTP **400** (earlier alphas surfaced it in-band on +> 200). The spec mandates 400 for this error wherever it arises; the JSON-RPC body is +> unchanged. This applies to a handler-thrown `-32021` too: a proxy relaying a +> downstream server's `-32021` should translate it (its `requiredCapabilities` +> describes the downstream hop's envelope) rather than rethrow the bare error. Every +> other handler-produced code (including a relayed `-32020`/`-32022`) +> keeps the in-band 200, and an exchange whose response stream is already open — the +> handler streamed first, or `responseMode: 'sse'` — keeps its committed 200 and +> carries the error in-stream. ### Server over stdio / long-lived connections: `serveStdio` diff --git a/packages/core-internal/src/shared/inboundClassification.ts b/packages/core-internal/src/shared/inboundClassification.ts index bffaae0645..35f694cae1 100644 --- a/packages/core-internal/src/shared/inboundClassification.ts +++ b/packages/core-internal/src/shared/inboundClassification.ts @@ -371,7 +371,9 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor * 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. + * domain-specific codes never become a blanket 500. The single exception is + * `MissingRequiredClientCapability` (-32021) — see + * {@linkcode httpStatusForErrorCode}. * * `-32602` (invalid params) deliberately has NO entry: the only invalid-params * rejection that maps to HTTP 400 is the classifier's own envelope rung @@ -390,11 +392,24 @@ export const LADDER_ERROR_HTTP_STATUS: Readonly> = { /** * 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}. + * HTTP 200 — the JSON-RPC error response is the payload, not an HTTP + * failure — with ONE exception: `MissingRequiredClientCapability` (-32021), + * whose 400 the spec mandates on the error itself with no origin condition, + * and which the SDK genuinely produces after dispatch (the `input_required` + * capability gate). A handler relaying some downstream peer's `-32020`/`-32022` + * is NOT that peer's spec error and stays in-band like every other handler + * code. `ladder` errors map through {@linkcode LADDER_ERROR_HTTP_STATUS}. + * + * The per-request transport intentionally does NOT delegate to this function: + * its `?? 400` ladder fallback is only correct for entry-gate codes known to + * the table, and would wrongly map dispatch-window errors outside it (a + * window `-32602` must stay in-band on 200). The transport indexes the table + * directly; keep the two in agreement when editing either. */ export function httpStatusForErrorCode(code: number, origin: 'ladder' | 'in-band'): number { - if (origin === 'in-band') return 200; + if (origin === 'in-band') { + return code === ProtocolErrorCode.MissingRequiredClientCapability ? 400 : 200; + } return LADDER_ERROR_HTTP_STATUS[code] ?? 400; } diff --git a/packages/core-internal/test/shared/errorHttpStatusMatrix.test.ts b/packages/core-internal/test/shared/errorHttpStatusMatrix.test.ts index d053f9eae8..db5711775a 100644 --- a/packages/core-internal/test/shared/errorHttpStatusMatrix.test.ts +++ b/packages/core-internal/test/shared/errorHttpStatusMatrix.test.ts @@ -6,9 +6,13 @@ * * - 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; + * - everything a request handler produces — including `-32603`, `-32602` and + * domain-specific codes — stays in-band on HTTP 200, never a blanket 500; + * - EXCEPT `-32021` (MissingRequiredClientCapability): the spec mandates its + * 400 per-error with no origin condition, and the `input_required` + * capability gate genuinely emits it after dispatch — so it alone is + * status-mapped wherever it arises. A handler relaying a downstream peer's + * `-32020`/`-32022` is not that peer's spec error and stays in-band; * - `-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. @@ -45,7 +49,7 @@ describe('the status matrix — pinned cells', () => { 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', () => { + test('every code except -32021 stays in-band on HTTP 200 when handler-originated — including internal errors and domain codes', () => { const handlerCodes = [ ProtocolErrorCode.InternalError, ProtocolErrorCode.InvalidParams, @@ -61,6 +65,16 @@ describe('the status matrix — pinned cells', () => { } }); + test('-32021 is the single code-keyed exception: its spec-mandated 400 applies wherever it arises', () => { + expect(httpStatusForErrorCode(ProtocolErrorCode.MissingRequiredClientCapability, 'in-band')).toBe(400); + expect(httpStatusForErrorCode(ProtocolErrorCode.MissingRequiredClientCapability, 'ladder')).toBe(400); + // The relay contract for the OTHER two spec-defined HTTP errors is + // origin-keyed: a handler-relayed -32020/-32022 is not this server's + // spec error and stays in-band. + expect(httpStatusForErrorCode(HEADER_MISMATCH_ERROR_CODE, 'in-band')).toBe(200); + expect(httpStatusForErrorCode(ProtocolErrorCode.UnsupportedProtocolVersion, '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); @@ -71,12 +85,19 @@ describe('the status matrix — pinned cells', () => { 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_022, -32_021, -32_020].sort((a, b) => a - b)); + test('the table is exactly the mandated set, keys and values (no silent growth)', () => { + // 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_022]: 400, + [-32_021]: 400, + [-32_020]: 400 + }); }); }); diff --git a/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts b/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts index 78613d4940..ace5d9d2c3 100644 --- a/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts +++ b/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts @@ -20,13 +20,7 @@ import { describe, expect, test } from 'vitest'; import type { InboundHttpRequest, InboundLadderRejection } from '../../src/shared/inboundClassification'; -import { - classifyInboundRequest, - httpStatusForErrorCode, - INBOUND_VALIDATION_LADDER, - LADDER_ERROR_HTTP_STATUS, - modernOnlyStrictRejection -} from '../../src/shared/inboundClassification'; +import { classifyInboundRequest, INBOUND_VALIDATION_LADDER, modernOnlyStrictRejection } from '../../src/shared/inboundClassification'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants'; const MODERN_REVISION = '2026-07-28'; @@ -397,37 +391,6 @@ 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_022]: 400, - [-32_021]: 400, - [-32_020]: 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_022, -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_022, 'ladder')).toBe(400); - expect(httpStatusForErrorCode(-32_021, 'ladder')).toBe(400); - expect(httpStatusForErrorCode(-32_020, 'ladder')).toBe(400); - }); -}); +// The error→HTTP-status policy (LADDER_ERROR_HTTP_STATUS / httpStatusForErrorCode) +// is pinned in errorHttpStatusMatrix.test.ts — the single test surface for that +// module. This file pins the classifier's cells only. diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 609772bde5..3f7632d570 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -669,9 +669,11 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // 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. + // constructed or dispatched. Answering at the entry short-circuits + // before factory construction and pins the spec-mandated HTTP 400 for + // this error unconditionally; a handler-time -32021 (the + // input_required gate) also maps to 400 at the per-request transport, + // but only while no response has been committed. if (route.messageKind === 'request') { const required = requiredClientCapabilitiesForRequest(route.message.method); if (required !== undefined) { diff --git a/packages/server/src/server/perRequestTransport.ts b/packages/server/src/server/perRequestTransport.ts index c9d24c6f63..4d96bc71c7 100644 --- a/packages/server/src/server/perRequestTransport.ts +++ b/packages/server/src/server/perRequestTransport.ts @@ -27,10 +27,14 @@ * 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). + * + * Consumers wiring this transport into a custom entry (instead of + * `createMcpHandler`) inherit the spec's per-error HTTP mandate with it: a + * terminal `MissingRequiredClientCapabilityError` (-32021) MUST answer 400, + * which this transport applies whenever the response is still uncommitted. */ import type { AuthInfo, - JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, @@ -45,6 +49,7 @@ import { isJSONRPCRequest, isJSONRPCResultResponse, LADDER_ERROR_HTTP_STATUS, + ProtocolErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; @@ -252,13 +257,27 @@ export class PerRequestHTTPServerTransport implements Transport { // 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. + // stay in-band on HTTP 200 — except + // MissingRequiredClientCapability (-32021), whose 400 the spec + // mandates per-error with no origin condition and whose only + // SDK-produced post-window source (the input_required capability + // gate) cannot fire any earlier — a handler-minted -32021 gets + // the same 400; a handler RELAYING a downstream peer's + // -32020/-32022 is not that peer's spec error and stays in-band. + // Must agree with httpStatusForErrorCode (core-internal), which + // is deliberately NOT called here: its `?? 400` ladder fallback + // would wrongly map window codes outside the table. + // The mapping applies only while no response has been committed: + // once the stream is open — the handler streamed first, or the + // exchange is forced-`sse` (which settles its 200 at dispatch + // end) — the status is on the wire and the error rides the + // stream. Pre-dispatch ladder rejections always precede the + // forced-`sse` upgrade, so they keep their mapped status in every + // response mode. + const errorCode = isJSONRPCErrorResponse(message) ? message.error.code : undefined; const ladderStatus = - this._dispatchWindowOpen && isJSONRPCErrorResponse(message) - ? LADDER_ERROR_HTTP_STATUS[(message as JSONRPCErrorResponse).error.code] + errorCode !== undefined && (this._dispatchWindowOpen || errorCode === ProtocolErrorCode.MissingRequiredClientCapability) + ? LADDER_ERROR_HTTP_STATUS[errorCode] : undefined; if (ladderStatus !== undefined && this._sse === undefined) { this.settleResponse(Response.json(message, { status: ladderStatus, headers: { 'Content-Type': 'application/json' } })); diff --git a/packages/server/test/server/perRequestTransport.test.ts b/packages/server/test/server/perRequestTransport.test.ts index 035e81b83b..364955615a 100644 --- a/packages/server/test/server/perRequestTransport.test.ts +++ b/packages/server/test/server/perRequestTransport.test.ts @@ -199,6 +199,9 @@ describe('HTTP status mapping', () => { }); it('keeps a handler-thrown unsupported-protocol-version error in-band on HTTP 200', async () => { + // A handler relaying a downstream peer's -32022 is not THIS server + // rejecting the caller's version; like the -32601 relay above it must + // not be re-mapped just because the ladder table maps that code. const { server } = modernServer({ toolsCallHandler: async () => { throw new ProtocolError(-32_022, 'Unsupported protocol version: 2099-01-01'); @@ -210,6 +213,41 @@ describe('HTTP status mapping', () => { expect(errorOf(await response.json())?.code).toBe(-32_022); }); + it('maps a post-dispatch -32021 (MissingRequiredClientCapability) to HTTP 400: the spec mandates that status per-error', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_021, 'Missing required client capabilities: sampling', { + requiredCapabilities: { sampling: {} } + }); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(400); + // The spec shape: `requiredCapabilities` is a ClientCapabilities + // OBJECT, never an array. + expect(errorOf(await response.json())).toMatchObject({ code: -32_021, data: { requiredCapabilities: { sampling: {} } } }); + }); + + it('leaves a post-dispatch -32021 on the already-open HTTP 200 stream when the handler streamed first', async () => { + // Once the lazy SSE upgrade has happened, the 200 is committed — and + // the error must still REACH the client as the stream's terminal + // frame rather than being swallowed by the status-mapping arm. + const { server } = modernServer({ + toolsCallHandler: async ctx => { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'capability-test', progress: 1 } }); + throw new ProtocolError(-32_021, 'Missing required client capabilities: sampling'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + const frames = (await response.text()).split('\n\n').filter(frame => frame.includes('data: ')); + const terminal = frames.at(-1)!; + expect(JSON.parse(terminal.split('data: ')[1]!)).toMatchObject({ id: 1, error: { code: -32_021 } }); + }); + it('keeps handler-produced invalid-params errors in-band on HTTP 200 (never status-mapped)', async () => { const { server } = modernServer({ toolsCallHandler: async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e74a91e86..0112367409 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1846,8 +1846,8 @@ importers: specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.7 - version: 0.2.0-alpha.7(@cfworker/json-schema@4.1.1) + specifier: 0.2.0-alpha.9 + version: 0.2.0-alpha.9(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../../packages/core-internal @@ -3127,8 +3127,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/conformance@0.2.0-alpha.7': - resolution: {integrity: sha512-S3usVyTWdEqvJpyGnXAz6Uj4yboBwT44lrmMqaQtxKcw4PK8H5XfdH5NQqNmDl9/zQbk4oDxtXqzUyGqTbEDzg==} + '@modelcontextprotocol/conformance@0.2.0-alpha.9': + resolution: {integrity: sha512-Bi5P5TQlOQGPJxCT7UAHbpG7wsR7sNZskHGtCoZBo6vDu416D2FXPgM4wKbg91teIgj4HjGkhnzlvP7U2dszfQ==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -7747,7 +7747,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/conformance@0.2.0-alpha.7(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/conformance@0.2.0-alpha.9(@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 index eebd8e1721..bcc454700b 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -30,5 +30,8 @@ client: [] server: [] # --- Carried-forward scenarios (also run by the 2025 legs) --- - # (empty: json-schema-2020-12 burned by SEP-2106 fixture; sep-2164-resource-not-found - # burned by the spec#2907 error-code renumber + alpha.5 referee.) + # (empty: json-schema-2020-12 burned by the SEP-2106 fixture; + # sep-2164-resource-not-found burned by the spec#2907 error-code renumber + + # alpha.5 referee; server-stateless burned by conformance#376 at alpha.9 — + # the referee's `requiredCapabilities` assertion now matches the schema's + # `ClientCapabilities` object, which is what this SDK emits.) diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index fe1e950a5d..79e5facbd2 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,7 +2,7 @@ # 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.7). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.9). Newer conformance releases # are adopted by deliberately bumping the package.json pin and reconciling # this file in the same change. # @@ -24,7 +24,7 @@ client: [] server: # --- SEP-2663 (io.modelcontextprotocol/tasks) — server SDK does not implement the tasks extension --- - # Extension-tagged scenarios; selected only by `--suite all` (the alpha.7 referee + # Extension-tagged scenarios; selected only by `--suite all` (the alpha.9 referee # has no server-side `--suite extensions`). The active/draft/2026 legs never select # them, so they cannot flag these entries as stale. `tasks-status-notifications` is # intentionally absent: the referee SKIPs it unconditionally (harness rewrite pending diff --git a/test/conformance/package.json b/test/conformance/package.json index 6c2c654663..a6b6b71611 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -38,7 +38,7 @@ "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.7", + "@modelcontextprotocol/conformance": "0.2.0-alpha.9", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index cc75173e10..425b4d6647 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -186,6 +186,63 @@ function createMcpServer() { }) ); + // SEP-2575 `server-stateless` diagnostic fixtures: the scenario hardcodes + // these three tool names, and at conformance alpha.8 (conformance#372) the + // checks behind them fail as untestable when the names are missing. + + // Requires the `sampling` client capability via an MRTR createMessage + // input request; the scenario calls it with empty clientCapabilities and + // expects -32021 over HTTP 400. + mcpServer.registerTool( + 'test_missing_capability', + { + description: 'SEP-2575: requires the `sampling` client capability (drives the -32021 undeclared-capability rejection)', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + if (ctx.mcpReq.inputResponses?.['llm_answer'] === undefined) { + return inputRequired({ + inputRequests: { + llm_answer: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'Reply with the single word: pong' } }], + maxTokens: 16 + }) + } + }); + } + return { content: [{ type: 'text', text: 'sampling round-trip complete' }] }; + } + ); + + // A plain successful call: the check only asserts that the response stream + // carries no independent top-level JSON-RPC request. It must not elicit + // (the scenario declares no `elicitation` capability); the referee's own + // reference server does not elicit here either. + mcpServer.registerTool( + 'test_streaming_elicitation', + { + description: 'SEP-2575: yields a response stream carrying no independent top-level JSON-RPC requests', + inputSchema: z.object({}) + }, + async (): Promise => ({ + content: [{ type: 'text', text: 'stream observed: result frames only, no top-level requests' }] + }) + ); + + // `ctx.mcpReq.log` is gated on the request's `_meta.logLevel`; the scenario + // omits it and asserts no notifications/message frame appears. + mcpServer.registerTool( + 'test_logging_tool', + { + description: 'SEP-2575: logs via ctx.mcpReq.log so the no-log-without-logLevel rule is exercised', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + await ctx.mcpReq.log('info', 'test_logging_tool ran (delivered only when the request set _meta.logLevel)'); + return { content: [{ type: 'text', text: 'logged through the request-scoped, logLevel-gated channel' }] }; + } + ); + // Simple text tool mcpServer.registerTool( 'test_simple_text',