From 85732bd6be10d4618d8e992fb6351b93e2759fba Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 19 Oct 2026 12:51:37 +0200 Subject: [PATCH] fix(tasks): ignore task metadata when tasks are not declared With tasks disabled, a 2025-11-25 tools/call carrying `task` was refused with -32601, a `taskSupport: 'required'` tool could never be called, and tools/list still advertised `execution.taskSupport`. The spec requires a receiver that does not declare the task capability for a request type to process it normally, ignoring task metadata. When tasks are not declared to the client (no task store, or a revision before 2025-11-25), tools/call now ignores `task` and skips the taskSupport checks, and tools/list omits `execution.taskSupport`. Fixes #339 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9 --- README.md | 4 ++ src/handlers.ts | 30 +++++++++++++-- test/revision-behavior.test.ts | 21 ++++++++++ test/tasks.test.ts | 70 ++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bfd15b3..cb14f72 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,10 @@ app.mcpAddTool({ }) ``` +Without `enableTasks`, `taskSupport` has no effect: `tools/list` leaves it out, and a +`tools/call` runs normally whether or not it carries a `task` field, as the spec requires +when the tasks capability is not declared. + A client opts in per call by adding a `task` field: ```jsonc diff --git a/src/handlers.ts b/src/handlers.ts index 985240c..504d635 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -255,6 +255,7 @@ async function mapWithConcurrency (items: T[], limit: number, fn: (item: T async function handleToolsList (request: JSONRPCRequest, dependencies: HandlerDependencies): Promise { const { tools, protocolVersion } = dependencies + const tasksDeclared = tasksDeclaredTo(dependencies) // Per-tool checks run concurrently (bounded); order stays registration order const registeredTools = Array.from(tools.values()) const accessResults = await mapWithConcurrency( @@ -274,6 +275,15 @@ async function handleToolsList (request: JSONRPCRequest, dependencies: HandlerDe if (serialized.outputSchema) { serialized.outputSchema = withSchemaDialect(serialized.outputSchema, protocolVersion) } + if (!tasksDeclared && serialized.execution?.taskSupport !== undefined) { + // taskSupport only means something on top of the tasks capability + const { taskSupport, ...execution } = serialized.execution + if (Object.keys(execution).length > 0) { + serialized.execution = execution + } else { + delete serialized.execution + } + } return serialized }), nextCursor: undefined @@ -426,9 +436,11 @@ async function handleToolsCall ( const params = paramsValidation.data const toolName = params.name - // A client on an older revision cannot have meant `task`, because we never - // declared the capability to it. The spec says to ignore it in that case. - const taskParams = supportsTasks(dependencies.protocolVersion) + // Without the tasks capability declared to this client (tasks disabled, or + // an older revision), the spec says to process the call normally, ignoring + // any task metadata, and a tool's `taskSupport` does not apply either. + const tasksDeclared = tasksDeclaredTo(dependencies) + const taskParams = tasksDeclared ? (request.params as { task?: { ttl?: unknown } | null } | undefined)?.task : undefined const ttl = taskParams?.ttl ?? undefined @@ -457,7 +469,9 @@ async function handleToolsCall ( // Decide up front whether this call runs as a task, so the rest of the // handler can stay unaware of it. - const augmentation = resolveTaskAugmentation(resolved.tool, taskParams !== undefined) + const augmentation = tasksDeclared + ? resolveTaskAugmentation(resolved.tool, taskParams !== undefined) + : { mode: 'direct' as const } if ('error' in augmentation) { await emitToolCallComplete('json-rpc', toolName, params.arguments || {}, { ok: false, reason: 'task-required' }, startedAt, dependencies) return createError(request.id, METHOD_NOT_FOUND, augmentation.error) @@ -1008,6 +1022,14 @@ function relatedTaskMeta (taskId: string): Record { return { [RELATED_TASK_META_KEY]: { taskId } } } +/** + * Whether `tasks.requests.tools.call` was declared to this client: tasks are + * enabled (there is a store) and the negotiated revision has them. + */ +function tasksDeclaredTo (dependencies: HandlerDependencies): boolean { + return dependencies.taskStore !== undefined && supportsTasks(dependencies.protocolVersion) +} + /** * Should this `tools/call` run as a task? * diff --git a/test/revision-behavior.test.ts b/test/revision-behavior.test.ts index 673f997..fc73dbf 100644 --- a/test/revision-behavior.test.ts +++ b/test/revision-behavior.test.ts @@ -140,6 +140,27 @@ describe('a client on 2025-03-26 keeps that revision\'s behavior', () => { t.assert.strictEqual(body.result.content[0].text, '5') t.assert.strictEqual(body.result.task, undefined) }) + + test('a tool with taskSupport required runs normally, as tasks were never declared', async (t: TestContext) => { + const app = Fastify({ logger: false }) + t.after(() => app.close()) + await app.register(mcpPlugin, { enableTasks: true }) + app.mcpAddTool({ + name: 'task-only', + description: 'Must be invoked as a task on 2025-11-25', + inputSchema: Type.Object({}), + execution: { taskSupport: 'required' } + } as any, async () => ({ content: [{ type: 'text', text: 'done' }] })) + await app.ready() + + const body = (await call(app, 'tools/call', { name: 'task-only', arguments: {} }, OLD)).json() + t.assert.strictEqual(body.error, undefined) + t.assert.strictEqual(body.result.content[0].text, 'done') + + // The 2025-11-25 client, to which tasks are declared, still gets the rule + const modern = (await call(app, 'tools/call', { name: 'task-only', arguments: {} }, NEW)).json() + t.assert.strictEqual(modern.error.code, METHOD_NOT_FOUND) + }) }) describe('the session version is authoritative', () => { diff --git a/test/tasks.test.ts b/test/tasks.test.ts index 361fe0d..0f54ac6 100644 --- a/test/tasks.test.ts +++ b/test/tasks.test.ts @@ -797,3 +797,73 @@ describe('tasks over the wire', () => { t.assert.strictEqual(init.result.capabilities.tasks, undefined) }) }) + +describe('task metadata with tasks disabled', () => { + async function buildDisabledApp (t: TestContext) { + const app = Fastify({ logger: false }) + t.after(() => app.close()) + await app.register(mcpPlugin) + + app.mcpAddTool({ + name: 'optional-task', + description: 'Optional task support', + inputSchema: Type.Object({ a: Type.Number(), b: Type.Number() }), + execution: { taskSupport: 'optional' } + } as any, async (params: any): Promise => ({ + content: [{ type: 'text', text: String(params.a + params.b) }] + })) + + app.mcpAddTool({ + name: 'task-only', + description: 'Must be invoked as a task', + inputSchema: Type.Object({}), + execution: { taskSupport: 'required' } + } as any, async (): Promise => ({ content: [{ type: 'text', text: 'done' }] })) + + app.mcpAddTool({ + name: 'plain', + description: 'No task support', + inputSchema: Type.Object({}) + } as any, async (): Promise => ({ content: [{ type: 'text', text: 'plain' }] })) + + await app.ready() + return app + } + + test('a tools/call carrying task is processed normally', async (t: TestContext) => { + const app = await buildDisabledApp(t) + + const body = await call(app, 'tools/call', { name: 'optional-task', arguments: { a: 2, b: 3 }, task: { ttl: 5000 } }) + t.assert.strictEqual(body.error, undefined) + t.assert.strictEqual(body.result.content[0].text, '5') + t.assert.strictEqual(body.result.task, undefined) + }) + + test('task metadata is ignored even on a tool that forbids tasks, or with a bad ttl', async (t: TestContext) => { + const app = await buildDisabledApp(t) + + const forbidden = await call(app, 'tools/call', { name: 'plain', arguments: {}, task: {} }) + t.assert.strictEqual(forbidden.result.content[0].text, 'plain') + + const badTtl = await call(app, 'tools/call', { name: 'plain', arguments: {}, task: { ttl: 'forever' } }) + t.assert.strictEqual(badTtl.result.content[0].text, 'plain') + }) + + test('a tool with taskSupport required can be called directly', async (t: TestContext) => { + const app = await buildDisabledApp(t) + + const body = await call(app, 'tools/call', { name: 'task-only', arguments: {} }) + t.assert.strictEqual(body.error, undefined) + t.assert.strictEqual(body.result.content[0].text, 'done') + }) + + test('tools/list does not advertise execution.taskSupport', async (t: TestContext) => { + const app = await buildDisabledApp(t) + + const body = await call(app, 'tools/list', {}) + t.assert.strictEqual(body.result.tools.length, 3) + for (const tool of body.result.tools) { + t.assert.strictEqual(tool.execution?.taskSupport, undefined, `${tool.name} must not advertise taskSupport`) + } + }) +})