Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/keep-resulttype-through-decode.md
Original file line number Diff line number Diff line change
@@ -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.
143 changes: 143 additions & 0 deletions packages/client/test/client/skillsDirectoryResultType.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>) {}

async start(): Promise<void> {}
async close(): Promise<void> {
this.onclose?.();
}
async send(message: JSONRPCMessage): Promise<void> {
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<string, Record<string, unknown>>): Promise<Client> {
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();
});
});
29 changes: 27 additions & 2 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -263,6 +263,31 @@ function liftWireOnlyMaterial<T extends JSONRPCRequest | JSONRPCNotification>(
* 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<T extends StandardSchemaV1>(
resultSchema: T,
lifted: unknown,
era: string
): Promise<StandardSchemaValidationResult<StandardSchemaV1.InferOutput<T>>> {
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
Expand Down Expand Up @@ -1551,7 +1576,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
}
const result = decoded.result;

validateStandardSchema(resultSchema, result).then(parseResult => {
validateLiftedCompleteResult(resultSchema, result, codec.era).then(parseResult => {
if (parseResult.success) {
resolve(parseResult.data);
} else {
Expand Down
135 changes: 135 additions & 0 deletions packages/core-internal/test/shared/extensionResultType.test.ts
Original file line number Diff line number Diff line change
@@ -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<BaseContext> {
protected assertCapabilityForMethod(): void {}
protected assertNotificationCapability(): void {}
protected assertRequestHandlerCapability(): void {}
protected buildContext(ctx: BaseContext): BaseContext {
return ctx;
}
}

async function wireWithRawResult(rawResult: unknown): Promise<TestProtocol> {
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<typeof serverTx.send>[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();
});
});
Loading