A check that cannot be exercised — the server under test lacks a diagnostic fixture tool, or advertises a feature it then rejects — was reported SKIPPED, which is excluded from pass/fail counts, exit codes, and the expected-failures baseline. A server could dodge the entire SEP-2575 capability-constraint and subscription surface (7 MUST-level checks) and still read green, and a baselined failing scenario whose checks degraded to SKIPPED flipped its baseline entry to stale, which reads as fixed. Such checks now fail with a 'Not testable:' message naming the missing prerequisite and details.untestable for tooling, per the policy in issue #248 (severity follows the requirement's keyword: MUST -> FAILURE, SHOULD -> WARNING). Legitimate skips remain: optional capabilities the server never declared, runner-level spec-version inapplicability, and the documented tasks-status-notifications harness gap. Also fixes a test-suite bug found en route: the stream mock's reader lacked releaseLock, so listenToStream's teardown threw and every stream-based assertion silently ran against zero frames.
547 lines
20 KiB
TypeScript
547 lines
20 KiB
TypeScript
/**
|
|
* SEP-2663 Tasks Extension — server lifecycle conformance.
|
|
*
|
|
* Tests a server that implements the io.modelcontextprotocol/tasks
|
|
* extension end-to-end: sync vs async dispatch, DetailedTask shape on
|
|
* tasks/get, tool errors vs protocol errors, and cancellation
|
|
* semantics.
|
|
*
|
|
* Required server fixtures (tools/list output must include all):
|
|
* - greet — sync-only, returns "Hello, {name}!"
|
|
* - slow_compute — task-supporting, sleeps N seconds
|
|
* - failing_job — task-supporting, returns a tool error
|
|
* - protocol_error_job — task-supporting, panics into a protocol error
|
|
*/
|
|
|
|
import { ClientScenario, ConformanceCheck } from '../../../types';
|
|
import type { Connection, RunContext } from '../../../connection';
|
|
import { SEP_2322_REF, SEP_2663_REF } from './mrtr-helpers';
|
|
import { errMsg, failureCheck } from './mrtr-helpers';
|
|
import { TASKS_EXTENSION_ID, waitForTerminal } from './helpers';
|
|
import { untestableCheck } from '../../untestable';
|
|
import { isIso8601 } from './mrtr-helpers';
|
|
|
|
export class TasksLifecycleScenario implements ClientScenario {
|
|
name = 'tasks-lifecycle';
|
|
readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const;
|
|
description = `Test SEP-2663 Tasks extension lifecycle on the server.
|
|
|
|
**Server Implementation Requirements (SEP-2663):**
|
|
|
|
The server MUST advertise \`io.modelcontextprotocol/tasks\` under
|
|
\`capabilities.extensions\` and gate the task surface on negotiation.
|
|
|
|
**Sync dispatch (no task created):**
|
|
- A \`tools/call\` against a sync-only tool MUST return a flat
|
|
\`ToolResult\` with \`resultType:"complete"\` and a \`content[]\` array.
|
|
- It MUST NOT carry \`taskId\` at the top level (that would imply a
|
|
CreateTaskResult).
|
|
|
|
**Server-directed task creation:**
|
|
- For task-supporting tools, the server decides whether to create a task —
|
|
the client MUST NOT need to opt in via a request param.
|
|
- The response MUST be a \`CreateTaskResult\` — a flat \`Result & Task\`
|
|
intersection: \`resultType:"task"\`, plus \`taskId\` / \`status\` /
|
|
\`createdAt\` / \`lastUpdatedAt\` / \`ttlMs\` at the top level.
|
|
There MUST NOT be a nested \`task\` wrapper key.
|
|
|
|
**tasks/get DetailedTask:**
|
|
- Working tasks return \`status\` and basic metadata; result/error are
|
|
absent.
|
|
- Completed tasks MUST inline the original tool result under \`result\`
|
|
with \`content[]\`. There is no separate \`tasks/result\` method.
|
|
|
|
**Tool errors vs protocol errors (SEP-2663 §error-semantics):**
|
|
- A tool that ran but reported an error MUST surface as
|
|
\`status:"completed"\` with \`result.isError:true\`. The status
|
|
\`"failed"\` is reserved for protocol-level errors.
|
|
- A protocol-level error (server crash, internal failure) MUST surface
|
|
as \`status:"failed"\` with an inlined \`error\` object (JSON-RPC
|
|
error shape: code/message/data) and MUST NOT carry \`result\`.
|
|
|
|
**Cancellation:**
|
|
- \`tasks/cancel\` MUST return an empty
|
|
\`{resultType:"complete"}\` ack — no task envelope (SEP-2322
|
|
discriminator). The cancelled status is observed via the next
|
|
\`tasks/get\`.
|
|
- \`tasks/cancel\` against a terminal task returns the same empty ack
|
|
(idempotent) — the spec reserves \`-32602\` for unknown taskIds only.
|
|
|
|
**Required server fixtures (\`tools/list\` MUST include all):**
|
|
- \`greet\` — sync-only, returns \`Hello, {name}!\`.
|
|
- \`slow_compute\` — task-supporting, \`seconds\`-second sleep then a
|
|
result. \`seconds: 0\` for the immediate path. MUST settle to
|
|
\`cancelled\` (not \`completed\`/\`failed\`) when \`tasks/cancel\`
|
|
arrives while running, so the lifecycle cancel check has a
|
|
deterministic terminal status.
|
|
- \`failing_job\` — task-supporting, always returns a tool execution
|
|
error after ~1s.
|
|
- \`protocol_error_job\` — task-supporting, panics into a protocol
|
|
error.`;
|
|
|
|
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
|
const checks: ConformanceCheck[] = [];
|
|
|
|
let conn: Connection;
|
|
try {
|
|
conn = await ctx.connect({
|
|
capabilities: {
|
|
elicitation: {},
|
|
sampling: {},
|
|
extensions: { [TASKS_EXTENSION_ID]: {} }
|
|
}
|
|
});
|
|
} catch (error) {
|
|
checks.push({
|
|
id: 'tasks-lifecycle-bootstrap',
|
|
name: 'TasksLifecycleBootstrap',
|
|
description:
|
|
'Initialize handshake declaring io.modelcontextprotocol/tasks extension succeeds',
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: `Failed to initialize: ${errMsg(error)}`,
|
|
specReferences: [SEP_2663_REF]
|
|
});
|
|
return checks;
|
|
}
|
|
|
|
// Check 1: sync tool call returns ToolResult, no task creation.
|
|
{
|
|
const id = 'tasks-sync-tool-call';
|
|
const name = 'TasksSyncToolCall';
|
|
const description =
|
|
'Sync tool returns ToolResult (resultType:"complete"), no taskId at top level';
|
|
try {
|
|
const result = (await conn.request('tools/call', {
|
|
name: 'greet',
|
|
arguments: { name: 'World' }
|
|
})) as any;
|
|
const errs: string[] = [];
|
|
if (result.resultType === 'task') {
|
|
errs.push('sync tool result MUST NOT carry resultType:"task"');
|
|
}
|
|
if (result.taskId) {
|
|
errs.push(
|
|
`sync tool result MUST NOT carry top-level taskId; got ${result.taskId}`
|
|
);
|
|
}
|
|
if (!Array.isArray(result.content) || result.content.length === 0) {
|
|
errs.push('sync tool result MUST carry a non-empty content[] array');
|
|
}
|
|
checks.push({
|
|
id,
|
|
name,
|
|
description,
|
|
status: errs.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: errs.length > 0 ? errs.join('; ') : undefined,
|
|
specReferences: [SEP_2663_REF, SEP_2322_REF],
|
|
details: {
|
|
resultType: result.resultType,
|
|
hasTaskId: Boolean(result.taskId),
|
|
contentLength: result.content?.length
|
|
}
|
|
});
|
|
} catch (error) {
|
|
checks.push(failureCheck(id, name, description, error, [SEP_2663_REF]));
|
|
}
|
|
}
|
|
|
|
// Check 2: server-directed task creation produces flat CreateTaskResult.
|
|
let workingTaskId: string | undefined;
|
|
{
|
|
const id = 'sep-2663-result-type-task-on-create';
|
|
const name = 'TasksServerTaskCreation';
|
|
const description =
|
|
'Task-supporting tool returns flat CreateTaskResult (no nested `task` wrapper)';
|
|
try {
|
|
const result = (await conn.request('tools/call', {
|
|
name: 'slow_compute',
|
|
arguments: { seconds: 2, label: 'lifecycle-create' }
|
|
})) as any;
|
|
const errs: string[] = [];
|
|
if (result.resultType !== 'task') {
|
|
errs.push(
|
|
`expected resultType:"task"; got ${JSON.stringify(result.resultType)}`
|
|
);
|
|
}
|
|
if (result.task) {
|
|
errs.push(
|
|
'CreateTaskResult MUST be flat (Result & Task); there must be no nested `task` wrapper key'
|
|
);
|
|
}
|
|
if (!result.taskId) {
|
|
errs.push('CreateTaskResult MUST carry top-level taskId');
|
|
}
|
|
if (!result.status) {
|
|
errs.push('CreateTaskResult MUST carry top-level status');
|
|
}
|
|
if ('result' in result) {
|
|
errs.push(
|
|
'CreateTaskResult MUST NOT carry `result` (lives on tasks/get DetailedTask)'
|
|
);
|
|
}
|
|
if ('error' in result) {
|
|
errs.push(
|
|
'CreateTaskResult MUST NOT carry `error` (lives on tasks/get DetailedTask)'
|
|
);
|
|
}
|
|
if ('inputRequests' in result) {
|
|
errs.push(
|
|
'CreateTaskResult MUST NOT carry `inputRequests` (lives on tasks/get DetailedTask)'
|
|
);
|
|
}
|
|
// Timestamps — both keys present, both ISO-8601 formatted. Per
|
|
// SEP-2663 these are required on every TaskInfoV2. See
|
|
// `mrtr-helpers.ts` for the regex rationale.
|
|
if (!isIso8601(result.createdAt)) {
|
|
errs.push(
|
|
`createdAt MUST be an ISO-8601 string; got ${JSON.stringify(result.createdAt)}`
|
|
);
|
|
}
|
|
if (!isIso8601(result.lastUpdatedAt)) {
|
|
errs.push(
|
|
`lastUpdatedAt MUST be an ISO-8601 string; got ${JSON.stringify(result.lastUpdatedAt)}`
|
|
);
|
|
}
|
|
if (result.taskId) workingTaskId = result.taskId;
|
|
checks.push({
|
|
id,
|
|
name,
|
|
description,
|
|
status: errs.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: errs.length > 0 ? errs.join('; ') : undefined,
|
|
specReferences: [SEP_2663_REF],
|
|
details: {
|
|
resultType: result.resultType,
|
|
taskId: result.taskId,
|
|
status: result.status
|
|
}
|
|
});
|
|
} catch (error) {
|
|
checks.push(failureCheck(id, name, description, error, [SEP_2663_REF]));
|
|
}
|
|
}
|
|
|
|
// Check 3: tasks/get during working state returns status + metadata.
|
|
{
|
|
const id = 'sep-2663-tasks-get-status-working';
|
|
const name = 'TasksGetDuringWorking';
|
|
const description =
|
|
'tasks/get returns status + metadata for an active task';
|
|
if (!workingTaskId) {
|
|
checks.push(
|
|
untestableCheck(
|
|
id,
|
|
name,
|
|
description,
|
|
'no task was created by the preceding step, so this check could not be exercised',
|
|
[SEP_2663_REF]
|
|
)
|
|
);
|
|
} else {
|
|
try {
|
|
const task = (await conn.request('tasks/get', {
|
|
taskId: workingTaskId
|
|
})) as any;
|
|
const errs: string[] = [];
|
|
if (task.taskId !== workingTaskId) {
|
|
errs.push(
|
|
`taskId mismatch: expected ${workingTaskId}, got ${task.taskId}`
|
|
);
|
|
}
|
|
if (!task.status) errs.push('tasks/get response MUST carry status');
|
|
checks.push({
|
|
id,
|
|
name,
|
|
description,
|
|
status: errs.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: errs.length > 0 ? errs.join('; ') : undefined,
|
|
specReferences: [SEP_2663_REF],
|
|
details: { status: task.status }
|
|
});
|
|
} catch (error) {
|
|
checks.push(
|
|
failureCheck(id, name, description, error, [SEP_2663_REF])
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check 4: terminal tasks/get inlines result with content[].
|
|
{
|
|
const id = 'sep-2663-tasks-get-status-completed';
|
|
const name = 'TasksGetTerminalInlinedResult';
|
|
const description =
|
|
'Completed task tasks/get inlines result with content[] (no separate tasks/result method)';
|
|
if (!workingTaskId) {
|
|
checks.push(
|
|
untestableCheck(
|
|
id,
|
|
name,
|
|
description,
|
|
'no task was created by the preceding step, so this check could not be exercised',
|
|
[SEP_2663_REF]
|
|
)
|
|
);
|
|
} else {
|
|
try {
|
|
const terminal = await waitForTerminal(conn, workingTaskId);
|
|
const errs: string[] = [];
|
|
if (terminal.status !== 'completed') {
|
|
errs.push(
|
|
`expected status:"completed"; got ${JSON.stringify(terminal.status)}`
|
|
);
|
|
}
|
|
if (!terminal.result) {
|
|
errs.push('completed task MUST inline `result`');
|
|
} else if (
|
|
!Array.isArray(terminal.result.content) ||
|
|
terminal.result.content.length === 0
|
|
) {
|
|
errs.push(
|
|
'completed task `result.content[]` MUST be a non-empty array'
|
|
);
|
|
}
|
|
checks.push({
|
|
id,
|
|
name,
|
|
description,
|
|
status: errs.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: errs.length > 0 ? errs.join('; ') : undefined,
|
|
specReferences: [SEP_2663_REF],
|
|
details: {
|
|
status: terminal.status,
|
|
hasResult: Boolean(terminal.result),
|
|
contentLength: terminal.result?.content?.length
|
|
}
|
|
});
|
|
} catch (error) {
|
|
checks.push(
|
|
failureCheck(id, name, description, error, [SEP_2663_REF])
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check 5: tool execution error → completed with isError:true.
|
|
{
|
|
const id = 'sep-2663-tool-error-uses-completed-status';
|
|
const name = 'TasksToolErrorCompletedIsError';
|
|
const description =
|
|
'Tool execution error reports as completed + result.isError (NOT failed)';
|
|
try {
|
|
const created = (await conn.request('tools/call', {
|
|
name: 'failing_job',
|
|
arguments: {}
|
|
})) as any;
|
|
const errs: string[] = [];
|
|
if (!created.taskId) {
|
|
errs.push('failing_job MUST create a task');
|
|
} else {
|
|
const terminal = await waitForTerminal(conn, created.taskId);
|
|
if (terminal.status !== 'completed') {
|
|
errs.push(
|
|
`tool error MUST surface as completed (not "${terminal.status}")`
|
|
);
|
|
}
|
|
if (!terminal.result) {
|
|
errs.push('completed task with tool error MUST carry `result`');
|
|
} else if (terminal.result.isError !== true) {
|
|
errs.push('result.isError MUST be true for tool execution errors');
|
|
}
|
|
}
|
|
checks.push({
|
|
id,
|
|
name,
|
|
description,
|
|
status: errs.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: errs.length > 0 ? errs.join('; ') : undefined,
|
|
specReferences: [SEP_2663_REF]
|
|
});
|
|
} catch (error) {
|
|
checks.push(failureCheck(id, name, description, error, [SEP_2663_REF]));
|
|
}
|
|
}
|
|
|
|
// Check 6: protocol-level error → failed with inlined error, no result.
|
|
{
|
|
const id = 'sep-2663-tasks-get-status-failed';
|
|
const name = 'TasksProtocolErrorFailedShape';
|
|
const description =
|
|
'Protocol-level error reports as failed + inlined error{code,message}, no result';
|
|
try {
|
|
const created = (await conn.request('tools/call', {
|
|
name: 'protocol_error_job',
|
|
arguments: {}
|
|
})) as any;
|
|
const errs: string[] = [];
|
|
if (!created.taskId) {
|
|
errs.push('protocol_error_job MUST create a task');
|
|
} else {
|
|
const terminal = await waitForTerminal(conn, created.taskId);
|
|
if (terminal.status !== 'failed') {
|
|
errs.push(
|
|
`protocol error MUST surface as failed (not "${terminal.status}")`
|
|
);
|
|
}
|
|
if (!terminal.error) {
|
|
errs.push('failed task MUST carry inlined `error`');
|
|
} else {
|
|
if (typeof terminal.error.code !== 'number') {
|
|
errs.push('failed task error MUST carry numeric `code`');
|
|
}
|
|
if (typeof terminal.error.message !== 'string') {
|
|
errs.push('failed task error MUST carry string `message`');
|
|
}
|
|
}
|
|
if (terminal.result) {
|
|
errs.push('failed task MUST NOT carry `result`');
|
|
}
|
|
}
|
|
checks.push({
|
|
id,
|
|
name,
|
|
description,
|
|
status: errs.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: errs.length > 0 ? errs.join('; ') : undefined,
|
|
specReferences: [SEP_2663_REF]
|
|
});
|
|
} catch (error) {
|
|
checks.push(failureCheck(id, name, description, error, [SEP_2663_REF]));
|
|
}
|
|
}
|
|
|
|
// Check 7: tasks/cancel returns empty {resultType:"complete"} ack;
|
|
// status settles to cancelled.
|
|
{
|
|
const id = 'sep-2663-cancel-ack-empty-result';
|
|
const name = 'TasksCancelEmptyAck';
|
|
const description =
|
|
'tasks/cancel returns {resultType:"complete"} ack; status settles to cancelled';
|
|
let cancelTaskId: string | undefined;
|
|
try {
|
|
const created = (await conn.request('tools/call', {
|
|
name: 'slow_compute',
|
|
arguments: { seconds: 60, label: 'lifecycle-cancel' }
|
|
})) as any;
|
|
cancelTaskId = created.taskId;
|
|
if (!cancelTaskId) {
|
|
checks.push({
|
|
id,
|
|
name,
|
|
description,
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: 'slow_compute did not create a task',
|
|
specReferences: [SEP_2663_REF, SEP_2322_REF]
|
|
});
|
|
} else {
|
|
const ack = (await conn.request('tasks/cancel', {
|
|
taskId: cancelTaskId
|
|
})) as any;
|
|
const errs: string[] = [];
|
|
if (ack?.resultType !== 'complete') {
|
|
errs.push(
|
|
`cancel ack MUST carry resultType:"complete"; got resultType=${ack?.resultType}`
|
|
);
|
|
}
|
|
// Task-envelope fields MUST NOT appear on the ack; _meta and
|
|
// other result-shape metadata are permitted.
|
|
const ackOffenders = (
|
|
['taskId', 'status', 'result', 'error', 'inputRequests'] as const
|
|
).filter((f) => f in ack);
|
|
if (ackOffenders.length > 0) {
|
|
errs.push(
|
|
`cancel ack MUST NOT carry task-envelope fields; got: ${ackOffenders.join(', ')}`
|
|
);
|
|
}
|
|
// SEP-2663 §Task Cancellation: transition to `cancelled` is not
|
|
// guaranteed; record the settled status as diagnostic detail only.
|
|
const after = await waitForTerminal(conn, cancelTaskId);
|
|
checks.push({
|
|
id,
|
|
name,
|
|
description,
|
|
status: errs.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: errs.length > 0 ? errs.join('; ') : undefined,
|
|
specReferences: [SEP_2663_REF, SEP_2322_REF],
|
|
details: { cancelAck: ack, statusAfterCancel: after.status }
|
|
});
|
|
}
|
|
} catch (error) {
|
|
checks.push(failureCheck(id, name, description, error, [SEP_2663_REF]));
|
|
}
|
|
}
|
|
|
|
// Check 8: tasks/cancel on a terminal task is idempotent — returns
|
|
// the same {resultType:"complete"} empty-ack as on an active task,
|
|
// rather than -32602. Required so clients don't have to handle the
|
|
// race where a task terminates between observation and the cancel
|
|
// request.
|
|
{
|
|
const id = 'tasks-cancel-terminal-idempotent-ack';
|
|
const name = 'TasksCancelTerminalIdempotentAck';
|
|
const description =
|
|
'tasks/cancel on a terminal task returns the same empty-ack as on an active task (idempotent)';
|
|
try {
|
|
const created = (await conn.request('tools/call', {
|
|
name: 'slow_compute',
|
|
arguments: { seconds: 1, label: 'lifecycle-cancel-terminal' }
|
|
})) as any;
|
|
const completedTaskId = created.taskId;
|
|
if (!completedTaskId) {
|
|
checks.push({
|
|
id,
|
|
name,
|
|
description,
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: 'slow_compute did not create a task',
|
|
specReferences: [SEP_2663_REF]
|
|
});
|
|
} else {
|
|
await waitForTerminal(conn, completedTaskId);
|
|
const ack = (await conn.request('tasks/cancel', {
|
|
taskId: completedTaskId
|
|
})) as any;
|
|
const errs: string[] = [];
|
|
if (ack?.resultType !== 'complete') {
|
|
errs.push(
|
|
`idempotent cancel ack MUST carry resultType:"complete"; got resultType=${ack?.resultType}`
|
|
);
|
|
}
|
|
const ackOffenders = (
|
|
['taskId', 'status', 'result', 'error', 'inputRequests'] as const
|
|
).filter((f) => f in ack);
|
|
if (ackOffenders.length > 0) {
|
|
errs.push(
|
|
`idempotent cancel ack MUST NOT carry task-envelope fields; got: ${ackOffenders.join(', ')}`
|
|
);
|
|
}
|
|
checks.push({
|
|
id,
|
|
name,
|
|
description,
|
|
status: errs.length === 0 ? 'SUCCESS' : 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: errs.length > 0 ? errs.join('; ') : undefined,
|
|
specReferences: [SEP_2663_REF],
|
|
details: { cancelAck: ack }
|
|
});
|
|
}
|
|
} catch (error) {
|
|
checks.push(failureCheck(id, name, description, error, [SEP_2663_REF]));
|
|
}
|
|
}
|
|
|
|
await conn.close();
|
|
return checks;
|
|
}
|
|
}
|