Skip to content
Open
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
84 changes: 74 additions & 10 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -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++,
Expand Down Expand Up @@ -287,7 +288,7 @@ async function runRequestMetadataClient(serverUrl: string): Promise<void> {
serverSupported.includes(v)
);
if (mutuallySupported.length > 0) {
activeVersion = mutuallySupported[0];
activeVersion = mutuallySupported[0] as SpecVersion;
logger.debug(
`Mutually supported version found: ${activeVersion}. Retrying...`
);
Expand Down Expand Up @@ -1005,6 +1006,69 @@ async function runMRTRClient(serverUrl: string): Promise<void> {

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<void> {
const taskCapabilities = {
...STATELESS_META_BASE['io.modelcontextprotocol/clientCapabilities'],
extensions: { [TASKS_EXTENSION_ID]: {} }
};
const request = (method: string, params: Record<string, unknown> = {}) =>
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
// ============================================================================
Expand Down
60 changes: 60 additions & 0 deletions examples/clients/typescript/tasks-client-no-poll.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const request = async (
method: string,
params: Record<string, unknown> = {}
): Promise<Record<string, unknown>> => {
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<string, unknown>;
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 <server-url>');
10 changes: 10 additions & 0 deletions src/runner/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
9 changes: 6 additions & 3 deletions src/runner/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}

Expand Down
9 changes: 5 additions & 4 deletions src/runner/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
52 changes: 52 additions & 0 deletions src/scenarios/client/tasks-create-handling.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading