-
Notifications
You must be signed in to change notification settings - Fork 1.8k
[v2] Make ToolTaskHandler.getTask/getTaskResult optional and actually invoke them #1764
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0f7ba7c
607e0db
5e1e165
86af2b6
ce8699c
456c606
56f0082
c08d641
72020e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| --- | ||
| "@modelcontextprotocol/core": minor | ||
| "@modelcontextprotocol/server": minor | ||
| --- | ||
|
|
||
| Make `ToolTaskHandler.getTask`/`getTaskResult` optional and actually invoke them | ||
|
|
||
| **Bug fix:** `getTask` and `getTaskResult` handlers registered via `registerToolTask` were never invoked — `tasks/get` and `tasks/result` requests always hit `TaskStore` directly. | ||
|
|
||
| **Breaking changes (experimental API):** | ||
|
|
||
| - `getTask` and `getTaskResult` are now **optional** on `ToolTaskHandler`. When omitted, `TaskStore` handles the requests (previous de-facto behavior). | ||
| - `TaskRequestHandler` signature changed: handlers receive only `(ctx: TaskServerContext)`, not the tool's input arguments. | ||
|
|
||
| **Migration:** If your handlers just delegated to `ctx.task.store`, delete them. If you're proxying an external job system (Step Functions, CI/CD pipelines), keep them and drop the `args` parameter. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,13 +5,25 @@ | |
| * @experimental | ||
| */ | ||
|
|
||
| import type { StandardSchemaWithJSON, TaskToolExecution, ToolAnnotations, ToolExecution } from '@modelcontextprotocol/core'; | ||
| import type { | ||
| BaseContext, | ||
| CallToolResult, | ||
| GetTaskResult, | ||
| ServerContext, | ||
| StandardSchemaWithJSON, | ||
| TaskManager, | ||
| TaskServerContext, | ||
| TaskToolExecution, | ||
| ToolAnnotations, | ||
| ToolExecution | ||
| } from '@modelcontextprotocol/core'; | ||
|
|
||
| import type { AnyToolHandler, McpServer, RegisteredTool } from '../../server/mcp.js'; | ||
| import type { ToolTaskHandler } from './interfaces.js'; | ||
| import { isToolTaskHandler } from './interfaces.js'; | ||
|
|
||
| /** | ||
| * Internal interface for accessing {@linkcode McpServer}'s private _createRegisteredTool method. | ||
| * Internal interface for accessing {@linkcode McpServer}'s private members. | ||
| * @internal | ||
| */ | ||
| interface McpServerInternal { | ||
|
|
@@ -26,6 +38,7 @@ | |
| _meta: Record<string, unknown> | undefined, | ||
| handler: AnyToolHandler<StandardSchemaWithJSON | undefined> | ||
| ): RegisteredTool; | ||
| _registeredTools: { [name: string]: RegisteredTool }; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -39,14 +52,74 @@ | |
| * @experimental | ||
| */ | ||
| export class ExperimentalMcpServerTasks { | ||
| /** | ||
| * Maps taskId → toolName for tasks whose handlers define custom | ||
| * `getTask` or `getTaskResult`. In-memory only; after a server restart | ||
| * or on a different instance, lookups fall through to the TaskStore. | ||
| */ | ||
| private _taskToTool = new Map<string, string>(); | ||
|
|
||
| constructor(private readonly _mcpServer: McpServer) {} | ||
|
|
||
| /** @internal */ | ||
| _installOverrides(taskManager: TaskManager): void { | ||
| taskManager.setTaskOverrides({ | ||
| getTask: (taskId, ctx) => this._dispatch(taskId, ctx, 'getTask'), | ||
| getTaskResult: (taskId, ctx) => this._dispatch(taskId, ctx, 'getTaskResult') | ||
| }); | ||
| } | ||
|
|
||
| /** @internal */ | ||
| _recordTask(taskId: string, toolName: string): void { | ||
| const tool = (this._mcpServer as unknown as McpServerInternal)._registeredTools[toolName]; | ||
| if (tool && isToolTaskHandler(tool.handler) && (tool.handler.getTask || tool.handler.getTaskResult)) { | ||
| this._taskToTool.set(taskId, toolName); | ||
| } | ||
claude[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /** @internal */ | ||
| onClose(): void { | ||
| this._taskToTool.clear(); | ||
| } | ||
|
|
||
| private async _dispatch<M extends 'getTask' | 'getTaskResult'>( | ||
| taskId: string, | ||
| ctx: BaseContext, | ||
| method: M | ||
| ): Promise<(M extends 'getTask' ? GetTaskResult : CallToolResult) | undefined> { | ||
| const toolName = this._taskToTool.get(taskId); | ||
| if (!toolName) return undefined; | ||
|
|
||
| const tool = (this._mcpServer as unknown as McpServerInternal)._registeredTools[toolName]; | ||
| if (!tool || !isToolTaskHandler(tool.handler)) return undefined; | ||
|
|
||
| const handler = tool.handler[method]; | ||
| if (!handler) return undefined; | ||
|
|
||
| const serverCtx = ctx as ServerContext; | ||
| if (!serverCtx.task?.store) return undefined; | ||
|
|
||
| const taskCtx: TaskServerContext = { | ||
| ...serverCtx, | ||
| task: { ...serverCtx.task, id: taskId, store: serverCtx.task.store } | ||
| }; | ||
|
|
||
| const result = (await handler(taskCtx)) as M extends 'getTask' ? GetTaskResult : CallToolResult; | ||
| // getTaskResult is terminal — drop the mapping only after the handler resolves | ||
| // so a transient throw doesn't orphan the task on retry. | ||
| if (method === 'getTaskResult') { | ||
| this._taskToTool.delete(taskId); | ||
| } | ||
| return result; | ||
|
Check failure on line 113 in packages/server/src/experimental/tasks/mcpServer.ts
|
||
|
Comment on lines
+108
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 After a successful Extended reasoning...What the bug is and how it manifests This PR intentionally moves The specific code path that triggers it
Why existing code does not prevent it The comment on lines 108–109 of mcpServer.ts acknowledges the transient-throw retry case: "getTaskResult is terminal — drop the mapping only after the handler resolves so a transient throw doesn't orphan the task on retry." This reasoning is correct for handler throws. But once the handler succeeds and the entry is deleted, the SDK has no mechanism to distinguish a first-time call from a retry-after-lost-response. The local What the impact is For the primary new use case introduced by this PR — proxying external job systems (AWS Step Functions, CI/CD pipelines) — any dropped HTTP connection after a completed How to fix it Option A (result cache with TTL): After Option B (idempotent re-invoke): Restore the Option C (sentinel in TaskStore): After Step-by-step proof
|
||
| } | ||
claude[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Registers a task-based tool with a config object and handler. | ||
| * | ||
| * Task-based tools support long-running operations that can be polled for status | ||
| * and results. The handler must implement {@linkcode ToolTaskHandler.createTask | createTask}, {@linkcode ToolTaskHandler.getTask | getTask}, and {@linkcode ToolTaskHandler.getTaskResult | getTaskResult} | ||
| * methods. | ||
| * and results. The handler implements {@linkcode ToolTaskHandler.createTask | createTask} | ||
| * to start the task; subsequent `tasks/get` and `tasks/result` requests are served | ||
| * from the configured `TaskStore`. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
|
|
@@ -59,19 +132,13 @@ | |
| * const task = await ctx.task.store.createTask({ ttl: 300000 }); | ||
| * startBackgroundWork(task.taskId, args); | ||
| * return { task }; | ||
| * }, | ||
| * getTask: async (args, ctx) => { | ||
| * return ctx.task.store.getTask(ctx.task.id); | ||
| * }, | ||
| * getTaskResult: async (args, ctx) => { | ||
| * return ctx.task.store.getTaskResult(ctx.task.id); | ||
| * } | ||
| * }); | ||
| * ``` | ||
| * | ||
| * @param name - The tool name | ||
| * @param config - Tool configuration (description, schemas, etc.) | ||
| * @param handler - Task handler with {@linkcode ToolTaskHandler.createTask | createTask}, {@linkcode ToolTaskHandler.getTask | getTask}, {@linkcode ToolTaskHandler.getTaskResult | getTaskResult} methods | ||
| * @param handler - Task handler with {@linkcode ToolTaskHandler.createTask | createTask} | ||
| * @returns {@linkcode server/mcp.RegisteredTool | RegisteredTool} for managing the tool's lifecycle | ||
| * | ||
| * @experimental | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 docs/migration-SKILL.md is missing the ToolTaskHandler breaking-change entry required by CLAUDE.md. Users or LLM tools relying on migration-SKILL.md for mechanical migration will not be guided to remove boilerplate getTask/getTaskResult handlers or update the TaskRequestHandler signature from (args, ctx) to (ctx), which will cause compile errors.
Extended reasoning...
CLAUDE.md requirement (lines 27-30): 'When making breaking changes, document them in both: docs/migration.md — human-readable guide with before/after code examples AND docs/migration-SKILL.md — LLM-optimized mapping tables for mechanical migration.' This is an explicit, unambiguous policy.
What the PR changed: A new section was added to docs/migration.md (lines 503–530) documenting two breaking changes to the experimental ToolTaskHandler API: (1) getTask and getTaskResult are now optional, and (2) the TaskRequestHandler signature changed from (args, ctx) to (ctx). This section correctly provides before/after code examples.
What was omitted: docs/migration-SKILL.md has zero mentions of ToolTaskHandler, getTask, getTaskResult, or TaskRequestHandler. The file already contains a section 12 ('Experimental: TaskCreationParams.ttl no longer accepts null') demonstrating that experimental-API breaking changes are expected there. The omission is inconsistent and violates the documented process.
Why this matters: docs/migration.md explicitly promotes migration-SKILL.md as the LLM-optimized guide for mechanical migration. Users or tools (like Claude Code) that load migration-SKILL.md and search for getTask/getTaskResult guidance will find nothing. They will retain boilerplate handlers with the old (args, ctx) signature that now causes a TypeScript compile error, because the TaskRequestHandler type was changed to (ctx: TaskServerContext) => ... with no args parameter.
Step-by-step proof of impact:
Suggested fix: Add a new row or section to migration-SKILL.md (parallel to the existing section 12) with a concise mapping table covering: (a) delete getTask/getTaskResult if they delegate to ctx.task.store, and (b) drop the args parameter if keeping custom handlers for external-system proxying.