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. 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/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 637be389aa..c2a2fa22e9 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -46,7 +46,7 @@ import { ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index'; -import type { StandardSchemaV1 } 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'; @@ -263,6 +263,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 +1576,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 { 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..7b5c06ccc3 --- /dev/null +++ b/packages/core-internal/test/shared/extensionResultType.test.ts @@ -0,0 +1,135 @@ +/** + * 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(); + }); +});