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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 26 additions & 4 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ async function mapWithConcurrency<T, R> (items: T[], limit: number, fn: (item: T

async function handleToolsList (request: JSONRPCRequest, dependencies: HandlerDependencies): Promise<JSONRPCResponse> {
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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1008,6 +1022,14 @@ function relatedTaskMeta (taskId: string): Record<string, unknown> {
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?
*
Expand Down
21 changes: 21 additions & 0 deletions test/revision-behavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
70 changes: 70 additions & 0 deletions test/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CallToolResult> => ({
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<CallToolResult> => ({ content: [{ type: 'text', text: 'done' }] }))

app.mcpAddTool({
name: 'plain',
description: 'No task support',
inputSchema: Type.Object({})
} as any, async (): Promise<CallToolResult> => ({ 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`)
}
})
})
Loading