From 994b35dfafea679e6e9f4ba9d21ac44a193c9a91 Mon Sep 17 00:00:00 2001 From: Michele Montedoro <306127577+brixton-guns@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:48:16 +0200 Subject: [PATCH 1/2] feat(client): test SEP-2663 task result handling --- .../clients/typescript/everything-client.ts | 84 +++++- .../typescript/tasks-client-no-poll.ts | 60 +++++ src/runner/client.test.ts | 10 + src/runner/client.ts | 9 +- src/runner/server.ts | 9 +- .../client/tasks-create-handling.test.ts | 52 ++++ src/scenarios/client/tasks-create-handling.ts | 245 ++++++++++++++++++ src/scenarios/index.ts | 10 +- src/types.ts | 9 +- 9 files changed, 468 insertions(+), 20 deletions(-) create mode 100644 examples/clients/typescript/tasks-client-no-poll.ts create mode 100644 src/scenarios/client/tasks-create-handling.test.ts create mode 100644 src/scenarios/client/tasks-create-handling.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index c91d0b8b..c8c84616 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -28,8 +28,12 @@ import type { import { JWT_BEARER_GRANT_TYPE } from '../../../src/scenarios/client/auth/helpers/createWorkloadJwt.js'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { ClientConformanceContextSchema } from '../../../src/schemas/context.js'; -import { DRAFT_PROTOCOL_VERSION } from '../../../src/types.js'; +import { + DRAFT_PROTOCOL_VERSION, + type SpecVersion +} from '../../../src/types.js'; import { STATELESS_SPEC_VERSIONS } from '../../../src/connection/select.js'; +import { buildStandardHeaders } from '../../../src/connection/stateless.js'; import { auth, extractWWWAuthenticateParams @@ -100,7 +104,8 @@ const USE_STATELESS_LIFECYCLE = PROTOCOL_VERSION // Wire protocolVersion for stateless requests: the runner-resolved version // when available (so a dated stateless release is exercised under its own // identifier), the current draft otherwise. -const STATELESS_PROTOCOL_VERSION = PROTOCOL_VERSION ?? DRAFT_PROTOCOL_VERSION; +const STATELESS_PROTOCOL_VERSION = (PROTOCOL_VERSION ?? + DRAFT_PROTOCOL_VERSION) as SpecVersion; const STATELESS_META_BASE = { 'io.modelcontextprotocol/clientInfo': { @@ -128,13 +133,9 @@ async function statelessRequest( }; const response = await fetch(serverUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - // Servers built on the SDK's StreamableHTTPServerTransport reject - // requests that don't accept both JSON and SSE responses. - Accept: 'application/json, text/event-stream', - 'MCP-Protocol-Version': STATELESS_PROTOCOL_VERSION - }, + headers: buildStandardHeaders(method, params, { + specVersion: STATELESS_PROTOCOL_VERSION + }), body: JSON.stringify({ jsonrpc: '2.0', id: _nextStatelessId++, @@ -287,7 +288,7 @@ async function runRequestMetadataClient(serverUrl: string): Promise { serverSupported.includes(v) ); if (mutuallySupported.length > 0) { - activeVersion = mutuallySupported[0]; + activeVersion = mutuallySupported[0] as SpecVersion; logger.debug( `Mutually supported version found: ${activeVersion}. Retrying...` ); @@ -1005,6 +1006,69 @@ async function runMRTRClient(serverUrl: string): Promise { registerScenario('sep-2322-client-request-state', runMRTRClient); +// ============================================================================ +// Tasks extension client conformance (SEP-2663) +// ============================================================================ + +const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'; + +async function runTasksClientCreateHandling(serverUrl: string): Promise { + const taskCapabilities = { + ...STATELESS_META_BASE['io.modelcontextprotocol/clientCapabilities'], + extensions: { [TASKS_EXTENSION_ID]: {} } + }; + const request = (method: string, params: Record = {}) => + statelessRequest(serverUrl, method, { + ...params, + _meta: { + 'io.modelcontextprotocol/clientCapabilities': taskCapabilities + } + }); + + const discovery = await request('server/discover'); + if (!discovery?.capabilities?.extensions?.[TASKS_EXTENSION_ID]) { + throw new Error('Server did not advertise the Tasks extension'); + } + + await request('tools/list'); + const result = await request('tools/call', { + name: 'long_running_echo', + arguments: { text: 'hello' } + }); + + if (result?.resultType !== 'task') { + // A standard CallToolResult is also valid after negotiating the extension. + return; + } + + const taskId = result.taskId; + if (typeof taskId !== 'string') { + throw new Error('CreateTaskResult did not contain a taskId'); + } + + for (let attempt = 0; attempt < 20; attempt++) { + const task = await request('tasks/get', { taskId }); + if (task?.status === 'completed') { + if (!task.result) { + throw new Error('Completed task did not include its result'); + } + return; + } + if (task?.status === 'failed' || task?.status === 'cancelled') { + throw new Error(`Task terminated with status ${task.status}`); + } + const delay = + typeof task?.pollIntervalMs === 'number' ? task.pollIntervalMs : 0; + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw new Error('Task did not reach a terminal status'); +} + +registerScenario('tasks-client-create-handling', runTasksClientCreateHandling); + // ============================================================================ // WIF JWT-bearer scenario // ============================================================================ diff --git a/examples/clients/typescript/tasks-client-no-poll.ts b/examples/clients/typescript/tasks-client-no-poll.ts new file mode 100644 index 00000000..0fed6896 --- /dev/null +++ b/examples/clients/typescript/tasks-client-no-poll.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +/** + * Deliberately broken SEP-2663 client: it negotiates the Tasks extension and + * receives CreateTaskResult, but never follows the task with tasks/get. + */ + +import { DRAFT_PROTOCOL_VERSION } from '../../../src/types.js'; +import { buildStandardHeaders } from '../../../src/connection/stateless.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'; +let nextId = 1; + +export async function runClient(serverUrl: string): Promise { + const request = async ( + method: string, + params: Record = {} + ): Promise> => { + const id = nextId++; + const meta = { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { + name: 'broken-tasks-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': { + extensions: { [TASKS_EXTENSION_ID]: {} } + } + }; + const response = await fetch(serverUrl, { + method: 'POST', + headers: buildStandardHeaders(method, params, { + specVersion: DRAFT_PROTOCOL_VERSION + }), + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params: { ...params, _meta: meta } + }) + }); + const body = (await response.json()) as { + result?: Record; + error?: { message: string }; + }; + if (body.error) throw new Error(body.error.message); + return body.result ?? {}; + }; + + await request('server/discover'); + await request('tools/list'); + await request('tools/call', { + name: 'long_running_echo', + arguments: { text: 'hello' } + }); + // BUG: ignores CreateTaskResult and exits without tasks/get. +} + +runAsCli(runClient, import.meta.url, 'tasks-client-no-poll '); diff --git a/src/runner/client.test.ts b/src/runner/client.test.ts index 8ce464ac..9c0bb5a7 100644 --- a/src/runner/client.test.ts +++ b/src/runner/client.test.ts @@ -64,4 +64,14 @@ describe('runConformanceTest spec-version applicability', () => { expect(result.skipped).toBeUndefined(); expect(result.clientOutput?.stdout).toContain(LATEST_SPEC_VERSION); }, 30000); + + test('uses an extension baseSpecVersion when the scenario declares one', async () => { + const result = await runConformanceTest( + PRINT_VERSION_COMMAND, + 'tasks-client-create-handling', + 10000 + ); + expect(result.skipped).toBeUndefined(); + expect(result.clientOutput?.stdout).toContain(DRAFT_PROTOCOL_VERSION); + }, 30000); }); diff --git a/src/runner/client.ts b/src/runner/client.ts index 2f9898cf..115894f4 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -136,9 +136,12 @@ function resolveScenarioSpecVersion( ): SpecVersion { return ( specVersion ?? - ('introducedIn' in source && source.introducedIn === DRAFT_PROTOCOL_VERSION - ? DRAFT_PROTOCOL_VERSION - : LATEST_SPEC_VERSION) + ('extensionId' in source && source.baseSpecVersion !== undefined + ? source.baseSpecVersion + : 'introducedIn' in source && + source.introducedIn === DRAFT_PROTOCOL_VERSION + ? DRAFT_PROTOCOL_VERSION + : LATEST_SPEC_VERSION) ); } diff --git a/src/runner/server.ts b/src/runner/server.ts index d19f3405..e2ee6d6a 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -79,10 +79,11 @@ export async function runServerConformanceTest( // on draft, so they fall under the same inference. const resolvedSpecVersion = specVersion ?? - ('extensionId' in scenario.source || - scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION - ? DRAFT_PROTOCOL_VERSION - : LATEST_SPEC_VERSION); + ('extensionId' in scenario.source + ? (scenario.source.baseSpecVersion ?? DRAFT_PROTOCOL_VERSION) + : scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION + ? DRAFT_PROTOCOL_VERSION + : LATEST_SPEC_VERSION); console.log( `Running client scenario '${scenarioName}' against server: ${serverUrl}` diff --git a/src/scenarios/client/tasks-create-handling.test.ts b/src/scenarios/client/tasks-create-handling.test.ts new file mode 100644 index 00000000..1c4798fe --- /dev/null +++ b/src/scenarios/client/tasks-create-handling.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'vitest'; +import { getHandler } from '../../../examples/clients/typescript/everything-client'; +import { runClient as noPollClient } from '../../../examples/clients/typescript/tasks-client-no-poll'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; +import { listExtensionScenarios } from '../index'; +import { + InlineClientRunner, + runClientAgainstScenario +} from './auth/test_helpers/testClient'; + +const SCENARIO = 'tasks-client-create-handling'; + +describe('SEP-2663 Tasks client handling', () => { + test('everything-client handles CreateTaskResult and retrieves the result', async () => { + const handler = getHandler(SCENARIO); + expect(handler).toBeDefined(); + + const checks = await runClientAgainstScenario( + new InlineClientRunner(handler!), + SCENARIO, + { specVersion: DRAFT_PROTOCOL_VERSION } + ); + + expect(checks).toEqual([ + expect.objectContaining({ + id: 'sep-2663-client-handles-polymorphic-result', + status: 'SUCCESS' + }) + ]); + }); + + test('detects a client that ignores CreateTaskResult', async () => { + const checks = await runClientAgainstScenario( + new InlineClientRunner(noPollClient), + SCENARIO, + { + specVersion: DRAFT_PROTOCOL_VERSION, + expectedFailureSlugs: ['sep-2663-client-handles-polymorphic-result'] + } + ); + + expect(checks[0]).toMatchObject({ + status: 'FAILURE', + errorMessage: + 'Client received CreateTaskResult but did not retrieve it with tasks/get.' + }); + }); + + test('is selected by the extensions suite', () => { + expect(listExtensionScenarios()).toContain(SCENARIO); + }); +}); diff --git a/src/scenarios/client/tasks-create-handling.ts b/src/scenarios/client/tasks-create-handling.ts new file mode 100644 index 00000000..c064192f --- /dev/null +++ b/src/scenarios/client/tasks-create-handling.ts @@ -0,0 +1,245 @@ +/** + * SEP-2663 Tasks Extension — client polymorphic-result handling. + * + * The mock server returns a server-directed CreateTaskResult from tools/call. + * A conformant client follows the task handle with tasks/get and consumes the + * completed result instead of treating the task envelope as a CallToolResult. + */ + +import express from 'express'; +import { randomUUID } from 'node:crypto'; +import type { Server as HttpServer } from 'node:http'; +import type { ScenarioContext } from '../../mock-server'; +import { + validateStatelessRequest, + withRequiredDraftResultFields +} from '../../mock-server'; +import { + DRAFT_PROTOCOL_VERSION, + type ConformanceCheck, + type Scenario, + type ScenarioUrls +} from '../../types'; + +const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'; +const MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; +const CREATED_AT = '2026-07-19T00:00:00.000Z'; +const SEP_2663_REF = { + id: 'SEP-2663', + url: 'https://modelcontextprotocol.io/seps/2663-tasks-extension' +}; + +interface Observations { + toolCallSeen: boolean; + toolCallDeclaredExtension: boolean; + taskGetIds: string[]; +} + +function clientDeclaredTasks(params: Record): boolean { + const meta = params._meta as Record | undefined; + const capabilities = meta?.['io.modelcontextprotocol/clientCapabilities'] as + | Record + | undefined; + const extensions = capabilities?.extensions as + | Record + | undefined; + return extensions?.[TASKS_EXTENSION_ID] !== undefined; +} + +export class TasksClientCreateHandlingScenario implements Scenario { + name = 'tasks-client-create-handling'; + readonly source = { + extensionId: TASKS_EXTENSION_ID, + baseSpecVersion: DRAFT_PROTOCOL_VERSION + } as const; + description = + 'Tests that a client declaring the SEP-2663 Tasks extension handles a server-directed CreateTaskResult from tools/call and retrieves the completed result with tasks/get.'; + + private httpServer: HttpServer | null = null; + private taskId = randomUUID(); + private observations: Observations = this.newObservations(); + + async start(ctx: ScenarioContext): Promise { + this.taskId = randomUUID(); + this.observations = this.newObservations(); + + const app = express(); + app.use(express.json()); + + const serverCapabilities = { + tools: {}, + extensions: { [TASKS_EXTENSION_ID]: {} } + }; + + app.post('/mcp', (req, res) => { + const validation = validateStatelessRequest(req, serverCapabilities, [ + ctx.specVersion + ]); + if (validation.kind !== 'route') { + return res.status(validation.status).json(validation.body); + } + + const { id, method, params } = validation; + const error = ( + status: number, + code: number, + message: string, + data?: Record + ) => + res.status(status).json({ + jsonrpc: '2.0', + id, + error: { code, message, ...(data ? { data } : {}) } + }); + + if (method === 'tools/list') { + return res.json({ + jsonrpc: '2.0', + id, + result: withRequiredDraftResultFields(method, { + tools: [ + { + name: 'long_running_echo', + description: 'Returns its result through an MCP task.', + inputSchema: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'] + } + } + ] + }) + }); + } + + if (method === 'tools/call') { + this.observations.toolCallSeen = true; + this.observations.toolCallDeclaredExtension = + clientDeclaredTasks(params); + + if (!this.observations.toolCallDeclaredExtension) { + return error( + 400, + MISSING_REQUIRED_CLIENT_CAPABILITY, + `Missing required client capability: ${TASKS_EXTENSION_ID}`, + { + requiredCapabilities: { + extensions: { [TASKS_EXTENSION_ID]: {} } + } + } + ); + } + + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'task', + taskId: this.taskId, + status: 'working', + createdAt: CREATED_AT, + lastUpdatedAt: CREATED_AT, + ttlMs: 60_000, + pollIntervalMs: 1 + } + }); + } + + if (method === 'tasks/get') { + const taskId = params.taskId; + if (typeof taskId !== 'string' || taskId !== this.taskId) { + return error(400, -32602, 'Unknown taskId'); + } + + this.observations.taskGetIds.push(taskId); + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + taskId: this.taskId, + status: 'completed', + createdAt: CREATED_AT, + lastUpdatedAt: CREATED_AT, + ttlMs: 60_000, + pollIntervalMs: 1, + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'task-result-ok' }] + } + } + }); + } + + return error(404, -32601, `Method not found: ${method}`); + }); + + await new Promise((resolve, reject) => { + this.httpServer = app.listen(0); + this.httpServer.once('error', reject); + this.httpServer.once('listening', resolve); + }); + + const server = this.httpServer; + if (!server) throw new Error('Tasks conformance server did not start'); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { serverUrl: `http://localhost:${port}/mcp` }; + } + + async stop(): Promise { + if (!this.httpServer) return; + const server = this.httpServer; + this.httpServer = null; + await new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }); + } + + getChecks(): ConformanceCheck[] { + const polledCreatedTask = this.observations.taskGetIds.includes( + this.taskId + ); + const passed = + this.observations.toolCallSeen && + this.observations.toolCallDeclaredExtension && + polledCreatedTask; + + let errorMessage: string | undefined; + if (!this.observations.toolCallSeen) { + errorMessage = 'Client did not issue tools/call.'; + } else if (!this.observations.toolCallDeclaredExtension) { + errorMessage = `Client did not declare ${TASKS_EXTENSION_ID} in the tools/call per-request capabilities.`; + } else if (!polledCreatedTask) { + errorMessage = + 'Client received CreateTaskResult but did not retrieve it with tasks/get.'; + } + + return [ + { + id: 'sep-2663-client-handles-polymorphic-result', + name: 'TasksClientHandlesPolymorphicResult', + description: + 'Client handles CreateTaskResult from tools/call and retrieves the completed task result with tasks/get.', + status: passed ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage, + specReferences: [SEP_2663_REF], + details: { + toolCallSeen: this.observations.toolCallSeen, + declaredTasksExtension: this.observations.toolCallDeclaredExtension, + taskGetIds: [...this.observations.taskGetIds] + } + } + ]; + } + + private newObservations(): Observations { + return { + toolCallSeen: false, + toolCallDeclaredExtension: false, + taskGetIds: [] + }; + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index db1584ea..5fea134c 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -15,6 +15,7 @@ import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults import { SSERetryScenario } from './client/sse-retry'; import { RequestMetadataScenario } from './client/request-metadata'; import { MRTRClientScenario } from './client/mrtr-client'; +import { TasksClientCreateHandlingScenario } from './client/tasks-create-handling'; // Import all new server test scenarios import { ServerInitializeScenario } from './server/lifecycle'; @@ -311,7 +312,10 @@ const scenariosList: Scenario[] = [ new HttpInvalidToolHeadersScenario(), // JSON Schema network $ref dereferencing (SEP-2106) - new JsonSchemaRefDerefScenario() + new JsonSchemaRefDerefScenario(), + + // SEP-2663 Tasks extension client conformance + new TasksClientCreateHandlingScenario() ]; // Core scenarios (tier 1 requirements) @@ -371,7 +375,9 @@ export function listCoreScenarios(): string[] { } export function listExtensionScenarios(): string[] { - return extensionScenariosList.map((scenario) => scenario.name); + return scenariosList + .filter((scenario) => 'extensionId' in scenario.source) + .map((scenario) => scenario.name); } export function listBackcompatScenarios(): string[] { diff --git a/src/types.ts b/src/types.ts index a6e02251..2c5bd5e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -111,7 +111,14 @@ export type ScenarioSource = introducedIn: DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION; removedIn?: DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION; } - | { extensionId: ExtensionId }; + | { + extensionId: ExtensionId; + /** + * Core protocol version the extension is layered on for conformance + * runs. When omitted, runners retain their existing extension default. + */ + baseSpecVersion?: SpecVersion; + }; export interface ScenarioUrls { serverUrl: string; From fbec97c40f493b855a56682c73d6f75e2c75fc67 Mon Sep 17 00:00:00 2001 From: Michele Montedoro <306127577+brixton-guns@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:28:05 +0200 Subject: [PATCH 2/2] docs: clarify extension defaults and task observability --- src/scenarios/client/tasks-create-handling.ts | 7 ++++--- src/types.ts | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/scenarios/client/tasks-create-handling.ts b/src/scenarios/client/tasks-create-handling.ts index c064192f..8ac936b9 100644 --- a/src/scenarios/client/tasks-create-handling.ts +++ b/src/scenarios/client/tasks-create-handling.ts @@ -2,8 +2,9 @@ * SEP-2663 Tasks Extension — client polymorphic-result handling. * * The mock server returns a server-directed CreateTaskResult from tools/call. - * A conformant client follows the task handle with tasks/get and consumes the - * completed result instead of treating the task envelope as a CallToolResult. + * A conformant client follows the task handle with tasks/get instead of + * treating the task envelope as a CallToolResult. This black-box scenario can + * observe retrieval, but not application-level use of the completed result. */ import express from 'express'; @@ -221,7 +222,7 @@ export class TasksClientCreateHandlingScenario implements Scenario { id: 'sep-2663-client-handles-polymorphic-result', name: 'TasksClientHandlesPolymorphicResult', description: - 'Client handles CreateTaskResult from tools/call and retrieves the completed task result with tasks/get.', + 'Client handles CreateTaskResult from tools/call and retrieves the completed task result with tasks/get. This black-box check observes retrieval, not application-level use of the returned result.', status: passed ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage, diff --git a/src/types.ts b/src/types.ts index 2c5bd5e4..4c14068f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,7 +115,9 @@ export type ScenarioSource = extensionId: ExtensionId; /** * Core protocol version the extension is layered on for conformance - * runs. When omitted, runners retain their existing extension default. + * runs. When omitted, the client runner uses LATEST_SPEC_VERSION and the + * server runner uses DRAFT_PROTOCOL_VERSION, preserving their existing + * extension defaults. */ baseSpecVersion?: SpecVersion; };