From fdc0e4b3774821d683bd95af8f68d695f1981926 Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Fri, 11 Sep 2026 20:42:13 +0000 Subject: [PATCH 1/4] fix(client): restore resultType when validating lifted complete results decodeResult() already checks resultType === "complete" and strips it as part of complete-result lifting. Caller schemas that still model the 2026 wire envelope (Inspector skills/list, skills/get, and resources/directory/read) then reject the lifted object because resultType is gone. Retry the caller/registry schema with the already-validated discriminator restored only when the lifted object fails, so post-lift schemas that omit the field keep working unchanged. Fixes #2789 Co-authored-by: Tiago Vilas Boas --- packages/core-internal/src/shared/protocol.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 637be389aa..e5b7304a4b 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -47,6 +47,7 @@ import { SUPPORTED_PROTOCOL_VERSIONS } from '../types/index'; import type { StandardSchemaV1 } from '../util/standardSchema'; +import type { StandardSchemaValidationResult } from '../util/standardSchema'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema'; import { bootstrapOutboundCodec } from '../wire/bootstrap'; import type { LiftedWireMaterial, WireCodec } from '../wire/codec'; @@ -263,6 +264,31 @@ function liftWireOnlyMaterial( * typedMapAlignment suite pins (the result map deliberately excludes the * `tasks/*` methods, so the spec-method overload refuses them up front). */ +/** + * Validate a decoded complete result against the caller or registry schema. + * + * `decodeResult` consumes the 2026 `resultType` discriminator as part of + * complete-result lifting. Caller schemas that still model the wire envelope + * (Inspector `ModernListSkillsResultSchema`, `ModernGetSkillEnvelopeSchema`, + * `ModernDirectoryReadResultSchema`) re-require `resultType: "complete"` and + * would otherwise reject every spec-conforming payload. Restore the + * already-checked discriminator only when the lifted object fails, so + * post-lift schemas that omit the field (core list methods, strict + * `EmptyResult`) keep working unchanged. + */ +function validateLiftedCompleteResult( + resultSchema: T, + lifted: unknown, + era: string +): Promise>> { + return validateStandardSchema(resultSchema, lifted).then(parseResult => { + if (parseResult.success || era !== MODERN_WIRE_REVISION || !isPlainObject(lifted)) { + return parseResult; + } + return validateStandardSchema(resultSchema, { ...lifted, resultType: 'complete' }); + }); +} + function codecResultValidator(codec: WireCodec, method: string): StandardSchemaV1 | undefined { // Probe for result-registry membership through the function-only // contract: a `not-in-era` outcome means no result entry for this method @@ -1551,7 +1577,7 @@ export abstract class Protocol { } const result = decoded.result; - validateStandardSchema(resultSchema, result).then(parseResult => { + validateLiftedCompleteResult(resultSchema, result, codec.era).then(parseResult => { if (parseResult.success) { resolve(parseResult.data); } else { From 3cad92002d7da35e100404c808d340932e7a7974 Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Fri, 11 Sep 2026 20:42:17 +0000 Subject: [PATCH 2/4] test(client): accept spec-conforming skills and directory complete results Regression coverage for #2789: skills/list, skills/get, and resources/directory/read must succeed when the server sends a resultType: "complete" payload and the caller schema still requires that discriminator (the Inspector Modern* envelopes). Co-authored-by: Tiago Vilas Boas --- .../client/skillsDirectoryResultType.test.ts | 143 ++++++++++++++++++ .../test/shared/extensionResultType.test.ts | 133 ++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 packages/client/test/client/skillsDirectoryResultType.test.ts create mode 100644 packages/core-internal/test/shared/extensionResultType.test.ts diff --git a/packages/client/test/client/skillsDirectoryResultType.test.ts b/packages/client/test/client/skillsDirectoryResultType.test.ts new file mode 100644 index 0000000000..f48261f526 --- /dev/null +++ b/packages/client/test/client/skillsDirectoryResultType.test.ts @@ -0,0 +1,143 @@ +/** + * Client.request() must accept spec-conforming 2026-era results for the + * SEP-2640 extension methods. The Inspector drives these as + * `client.request(method, Modern*Schema)` — schemas that still require + * `resultType: "complete"` after the codec has lifted/stripped that field. + * + * @see https://github.com/modelcontextprotocol/typescript-sdk/issues/2789 + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { isJSONRPCRequest } from '@modelcontextprotocol/core-internal'; +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { Client } from '../../src/client/client'; + +const MODERN = '2026-07-28'; + +class ScriptedTransport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sessionId?: string; + + constructor(private readonly results: Record>) {} + + async start(): Promise {} + async close(): Promise { + this.onclose?.(); + } + async send(message: JSONRPCMessage): Promise { + if (!isJSONRPCRequest(message)) return; + const result = + message.method === 'server/discover' + ? { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { + resources: {}, + extensions: { 'io.modelcontextprotocol/skills': { directoryRead: true } } + }, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'repro-server', version: '1.0.0' } } + } + : this.results[message.method]; + if (result === undefined) return; + queueMicrotask(() => { + this.onmessage?.({ jsonrpc: '2.0', id: message.id, result }); + }); + } + setProtocolVersion(_version: string): void {} +} + +const SkillEntrySchema = z.looseObject({ + uri: z.string(), + frontmatter: z.looseObject({ + name: z.string().optional(), + description: z.string().optional() + }), + resources: z.union([z.literal('dynamic'), z.array(z.looseObject({ uri: z.string() }))]) +}); + +const ModernListSkillsResultSchema = z.looseObject({ + skills: z.array(SkillEntrySchema), + nextCursor: z.string().optional(), + resultType: z.literal('complete'), + ttlMs: z.int().min(0), + cacheScope: z.enum(['public', 'private']) +}); + +const ModernGetSkillEnvelopeSchema = z.looseObject({ + skill: SkillEntrySchema, + resultType: z.literal('complete') +}); + +const ModernDirectoryReadResultSchema = z.looseObject({ + resources: z.array(z.object({ uri: z.string(), name: z.string() })), + nextCursor: z.string().optional(), + resultType: z.literal('complete') +}); + +const SAMPLE_SKILL = { + uri: 'skill://example/demo', + frontmatter: { name: 'demo', description: 'A demo skill' }, + resources: 'dynamic' as const +}; + +async function connectClient(results: Record>): Promise { + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(new ScriptedTransport(results)); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + return client; +} + +describe('Client.request() skills/directory results with resultType: "complete" (#2789)', () => { + test('skills/list accepts a spec-conforming complete result', async () => { + const client = await connectClient({ + 'skills/list': { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + skills: [SAMPLE_SKILL] + } + }); + + const result = await client.request({ method: 'skills/list' }, ModernListSkillsResultSchema); + expect(result.skills).toEqual([SAMPLE_SKILL]); + expect(result.ttlMs).toBe(0); + expect(result.cacheScope).toBe('private'); + + await client.close(); + }); + + test('skills/get accepts a spec-conforming complete result', async () => { + const client = await connectClient({ + 'skills/get': { + resultType: 'complete', + skill: SAMPLE_SKILL + } + }); + + const result = await client.request({ method: 'skills/get', params: { uri: SAMPLE_SKILL.uri } }, ModernGetSkillEnvelopeSchema); + expect(result.skill).toEqual(SAMPLE_SKILL); + + await client.close(); + }); + + test('resources/directory/read accepts a spec-conforming complete result', async () => { + const child = { uri: 'file://project/src', name: 'src' }; + const client = await connectClient({ + 'resources/directory/read': { + resultType: 'complete', + resources: [child] + } + }); + + const result = await client.request( + { method: 'resources/directory/read', params: { uri: 'file://project' } }, + ModernDirectoryReadResultSchema + ); + expect(result.resources).toEqual([child]); + + await client.close(); + }); +}); diff --git a/packages/core-internal/test/shared/extensionResultType.test.ts b/packages/core-internal/test/shared/extensionResultType.test.ts new file mode 100644 index 0000000000..7f66dd5baa --- /dev/null +++ b/packages/core-internal/test/shared/extensionResultType.test.ts @@ -0,0 +1,133 @@ +/** + * Caller-supplied result schemas that still model the 2026 wire envelope + * (resultType: "complete") must accept a spec-conforming payload after + * decodeResult lifts/strips the discriminator. This is the skills/directory + * path used by the Inspector (SEP-2640): client.request(method, Modern*Schema). + * + * @see https://github.com/modelcontextprotocol/typescript-sdk/issues/2789 + */ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import type { BaseContext } from '../../src/shared/protocol'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol'; +import type { JSONRPCRequest } from '../../src/types/index'; +import { InMemoryTransport } from '../../src/util/inMemory'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +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; +} + +const SkillEntrySchema = z.looseObject({ + uri: z.string(), + frontmatter: z.looseObject({ + name: z.string().optional(), + description: z.string().optional() + }), + resources: z.union([z.literal('dynamic'), z.array(z.looseObject({ uri: z.string() }))]) +}); + +/** Mirrors Inspector ModernListSkillsResultSchema (wire envelope + list page). */ +const ModernListSkillsResultSchema = z.looseObject({ + skills: z.array(SkillEntrySchema), + nextCursor: z.string().optional(), + resultType: z.literal('complete'), + ttlMs: z.int().min(0), + cacheScope: z.enum(['public', 'private']) +}); + +/** Mirrors Inspector ModernGetSkillEnvelopeSchema. */ +const ModernGetSkillEnvelopeSchema = z.looseObject({ + skill: SkillEntrySchema, + resultType: z.literal('complete') +}); + +/** Mirrors Inspector ModernDirectoryReadResultSchema. */ +const ModernDirectoryReadResultSchema = z.looseObject({ + resources: z.array(z.object({ uri: z.string(), name: z.string() })), + nextCursor: z.string().optional(), + resultType: z.literal('complete') +}); + +const SAMPLE_SKILL = { + uri: 'skill://example/demo', + frontmatter: { name: 'demo', description: 'A demo skill' }, + resources: 'dynamic' as const +}; + +describe('caller schemas that require resultType after decodeResult lift (#2789)', () => { + test('skills/list accepts a spec-conforming resultType: "complete" payload', async () => { + const protocol = await wireWithRawResult({ + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + skills: [SAMPLE_SKILL] + }); + + const result = await protocol.request({ method: 'skills/list' }, ModernListSkillsResultSchema); + expect(result.skills).toEqual([SAMPLE_SKILL]); + expect(result.ttlMs).toBe(0); + expect(result.cacheScope).toBe('private'); + + await protocol.close(); + }); + + test('skills/get accepts a spec-conforming resultType: "complete" payload', async () => { + const protocol = await wireWithRawResult({ + resultType: 'complete', + skill: SAMPLE_SKILL + }); + + const result = await protocol.request({ method: 'skills/get', params: { uri: SAMPLE_SKILL.uri } }, ModernGetSkillEnvelopeSchema); + expect(result.skill).toEqual(SAMPLE_SKILL); + + await protocol.close(); + }); + + test('resources/directory/read accepts a spec-conforming resultType: "complete" payload', async () => { + const child = { uri: 'file://project/src', name: 'src' }; + const protocol = await wireWithRawResult({ + resultType: 'complete', + resources: [child] + }); + + const result = await protocol.request( + { method: 'resources/directory/read', params: { uri: 'file://project' } }, + ModernDirectoryReadResultSchema + ); + expect(result.resources).toEqual([child]); + + await protocol.close(); + }); + + test('a payload that is actually invalid still fails after the discriminator is restored', async () => { + const protocol = await wireWithRawResult({ + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + // skills is required + }); + + await expect(protocol.request({ method: 'skills/list' }, ModernListSkillsResultSchema)).rejects.toThrow(/Invalid result for skills\/list/); + + await protocol.close(); + }); +}); From adbccaede418619c2e05b1dbae46d81d3373986f Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Fri, 11 Sep 2026 20:44:05 +0000 Subject: [PATCH 3/4] style(core-internal): merge type imports and wrap long assertion Keep eslint import/no-duplicates and Prettier happy on the #2789 validation helper and its protocol-level regression. Co-authored-by: Tiago Vilas Boas --- packages/core-internal/src/shared/protocol.ts | 3 +-- .../core-internal/test/shared/extensionResultType.test.ts | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index e5b7304a4b..c2a2fa22e9 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -46,8 +46,7 @@ import { ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index'; -import type { StandardSchemaV1 } from '../util/standardSchema'; -import type { StandardSchemaValidationResult } from '../util/standardSchema'; +import type { StandardSchemaV1, StandardSchemaValidationResult } from '../util/standardSchema'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema'; import { bootstrapOutboundCodec } from '../wire/bootstrap'; import type { LiftedWireMaterial, WireCodec } from '../wire/codec'; diff --git a/packages/core-internal/test/shared/extensionResultType.test.ts b/packages/core-internal/test/shared/extensionResultType.test.ts index 7f66dd5baa..7b5c06ccc3 100644 --- a/packages/core-internal/test/shared/extensionResultType.test.ts +++ b/packages/core-internal/test/shared/extensionResultType.test.ts @@ -126,7 +126,9 @@ describe('caller schemas that require resultType after decodeResult lift (#2789) // skills is required }); - await expect(protocol.request({ method: 'skills/list' }, ModernListSkillsResultSchema)).rejects.toThrow(/Invalid result for skills\/list/); + await expect(protocol.request({ method: 'skills/list' }, ModernListSkillsResultSchema)).rejects.toThrow( + /Invalid result for skills\/list/ + ); await protocol.close(); }); From 23260414f825c6e796fe770d3f093656ab2f16ec Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Fri, 11 Sep 2026 20:45:10 +0000 Subject: [PATCH 4/4] chore(changeset): patch client for resultType validation fix Record the #2789 Client.request() skills/directory validation fix as a patch on @modelcontextprotocol/client. Co-authored-by: Tiago Vilas Boas --- .changeset/keep-resulttype-through-decode.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/keep-resulttype-through-decode.md diff --git a/.changeset/keep-resulttype-through-decode.md b/.changeset/keep-resulttype-through-decode.md new file mode 100644 index 0000000000..2760947ac9 --- /dev/null +++ b/.changeset/keep-resulttype-through-decode.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +`Client.request()` now accepts spec-conforming `skills/list`, `skills/get`, and `resources/directory/read` results whose caller schema still requires `resultType: "complete"`. The codec keeps lifting/stripping the discriminator; validation retries once with it restored so post-lift schemas that omit the field are unchanged. Fixes #2789.