-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[v1.x] Fix registerToolTask's getTask and getTaskResult handlers not being invoked #1335
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: v1.x
Are you sure you want to change the base?
Changes from all commits
7503353
fbe5df4
8ae7217
fd14b33
a4e24fb
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 |
|---|---|---|
|
|
@@ -82,9 +82,43 @@ export class McpServer { | |
| private _registeredTools: { [name: string]: RegisteredTool } = {}; | ||
| private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; | ||
| private _experimental?: { tasks: ExperimentalMcpServerTasks }; | ||
| private _taskToolMap: Map<string, string> = new Map(); | ||
|
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. 🟡 Extended reasoning...What the bug isThe How it manifestsEvery task creation adds a Step-by-step proof
Why existing code doesn't prevent itSearching for all references to ImpactEach entry is two short strings (taskId + toolName), so individual entries are small. For typical short-lived MCP server instances or low task throughput, this is unlikely to cause issues. However, for long-running servers processing a high volume of tasks (e.g., a persistent production server), memory usage will grow linearly and unboundedly over time. Suggested fixThe simplest approach would be to add a lazy cleanup in |
||
|
|
||
| constructor(serverInfo: Implementation, options?: ServerOptions) { | ||
| this.server = new Server(serverInfo, options); | ||
| const taskHandlerHooks = { | ||
| getTask: async (taskId: string, extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => { | ||
| // taskStore is guaranteed to exist here because Protocol only calls hooks when taskStore is configured | ||
| const taskStore = extra.taskStore!; | ||
| const handler = this._getTaskHandler(taskId); | ||
| if (handler) { | ||
| return await handler.getTask({ ...extra, taskId, taskStore }); | ||
| } | ||
| return await taskStore.getTask(taskId); | ||
| }, | ||
| getTaskResult: async (taskId: string, extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => { | ||
| const taskStore = extra.taskStore!; | ||
| const handler = this._getTaskHandler(taskId); | ||
| try { | ||
| if (handler) { | ||
| return await handler.getTaskResult({ ...extra, taskId, taskStore }); | ||
| } | ||
| return await taskStore.getTaskResult(taskId); | ||
| } finally { | ||
| // Once the result has been retrieved the task is complete; | ||
| // drop the taskId → toolName mapping to avoid unbounded growth. | ||
| this._taskToolMap.delete(taskId); | ||
| } | ||
| } | ||
| }; | ||
| this.server = new Server(serverInfo, { ...options, taskHandlerHooks }); | ||
| } | ||
|
|
||
| private _getTaskHandler(taskId: string): ToolTaskHandler<ZodRawShapeCompat | undefined> | null { | ||
| const toolName = this._taskToolMap.get(taskId); | ||
| if (!toolName) return null; | ||
| const tool = this._registeredTools[toolName]; | ||
| if (!tool || !('createTask' in (tool.handler as AnyToolHandler<ZodRawShapeCompat>))) return null; | ||
| return tool.handler as ToolTaskHandler<ZodRawShapeCompat | undefined>; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -215,6 +249,10 @@ export class McpServer { | |
|
|
||
| // Return CreateTaskResult immediately for task requests | ||
| if (isTaskRequest) { | ||
| const taskResult = result as CreateTaskResult; | ||
| if (taskResult.task?.taskId) { | ||
| this._taskToolMap.set(taskResult.task.taskId, request.params.name); | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
|
|
@@ -374,27 +412,28 @@ export class McpServer { | |
| const handler = tool.handler as ToolTaskHandler<ZodRawShapeCompat | undefined>; | ||
| const taskExtra = { ...extra, taskStore: extra.taskStore }; | ||
|
|
||
| const createTaskResult: CreateTaskResult = args // undefined only if tool.inputSchema is undefined | ||
| ? await Promise.resolve((handler as ToolTaskHandler<ZodRawShapeCompat>).createTask(args, taskExtra)) | ||
| : // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| await Promise.resolve(((handler as ToolTaskHandler<undefined>).createTask as any)(taskExtra)); | ||
| const wrappedHandler = toolTaskHandlerByArgs(handler, args); | ||
|
|
||
| const createTaskResult = await wrappedHandler.createTask(taskExtra); | ||
|
|
||
| // Poll until completion | ||
| const taskId = createTaskResult.task.taskId; | ||
| const taskExtraComplete = { ...extra, taskId, taskStore: extra.taskStore }; | ||
| let task = createTaskResult.task; | ||
| const pollInterval = task.pollInterval ?? 5000; | ||
|
|
||
| while (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') { | ||
| await new Promise(resolve => setTimeout(resolve, pollInterval)); | ||
| const updatedTask = await extra.taskStore.getTask(taskId); | ||
| const getTaskResult = await wrappedHandler.getTask(taskExtraComplete); | ||
| const updatedTask = getTaskResult; | ||
| if (!updatedTask) { | ||
| throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`); | ||
| } | ||
| task = updatedTask; | ||
| } | ||
|
|
||
| // Return the final result | ||
| return (await extra.taskStore.getTaskResult(taskId)) as CallToolResult; | ||
| return await wrappedHandler.getTaskResult(taskExtraComplete); | ||
| } | ||
|
|
||
| private _completionHandlerInitialized = false; | ||
|
|
@@ -1545,3 +1584,24 @@ const EMPTY_COMPLETION_RESULT: CompleteResult = { | |
| hasMore: false | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Wraps a tool task handler's createTask to handle args uniformly. | ||
| * getTask and getTaskResult don't take args, so they're passed through directly. | ||
| * @param handler The task handler to wrap. | ||
| * @param args The tool arguments. | ||
| * @returns A wrapped task handler for a tool, which only exposes a no-args interface for createTask. | ||
| */ | ||
| function toolTaskHandlerByArgs<Args extends AnySchema | ZodRawShapeCompat | undefined>( | ||
| handler: ToolTaskHandler<Args>, | ||
| args: unknown | ||
| ): ToolTaskHandler<undefined> { | ||
| return { | ||
| createTask: extra => | ||
| args // undefined only if tool.inputSchema is undefined | ||
| ? Promise.resolve((handler as ToolTaskHandler<ZodRawShapeCompat>).createTask(args, extra)) | ||
| : Promise.resolve((handler as ToolTaskHandler<undefined>).createTask(extra)), | ||
| getTask: handler.getTask, | ||
| getTaskResult: handler.getTaskResult | ||
| }; | ||
| } | ||
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.
🔴 Bug:
collect-user-info-taskat lines 382-388 still uses the old two-argument signature(_args, { taskId, taskStore })forgetTaskandgetTaskResult, while the PR updatedTaskRequestHandlerto accept a singleextraargument. This will cause aTypeError: Cannot destructure property 'taskId' of 'undefined'at runtime when these handlers are invoked, since the entire extra object is passed as the first argument andundefinedas the second. Thedelaytool at lines 608-614 was correctly updated but this tool was missed.Extended reasoning...
What the bug is
The PR changes
TaskRequestHandlerfrom a multi-argument type (BaseToolCallback<SendResultT, TaskRequestHandlerExtra, Args>) to a single-argument function type ((extra: TaskRequestHandlerExtra) => SendResultT | Promise<SendResultT>). This meansgetTaskandgetTaskResulthandlers now receive a single object containing{ taskId, taskStore, ...extra }instead of the old(args, extra)two-argument pattern.The specific code path
In
src/server/mcp.ts, theMcpServerconstructor sets uptaskHandlerHooksthat callhandler.getTask({ ...extra, taskId, taskStore })andhandler.getTaskResult({ ...extra, taskId, taskStore })with a single argument. Whencollect-user-info-task's handler at line 382 declaresasync getTask(_args, { taskId, taskStore: getTaskStore }), JavaScript passes the entire{ ...extra, taskId, taskStore }object as_argsandundefinedas the second parameter.Why existing code doesn't prevent it
The
delaytool was correctly updated in this PR (lines 608-614 in the diff show the fix from(_args, { taskId, taskStore })to({ taskId, taskStore })), but thecollect-user-info-tasktool at lines 382-388 was overlooked. TypeScript should catch this as a compilation error since a 2-parameter function is not assignable to the new 1-parameterTaskRequestHandlertype, but example files may not be compiled as part of CI.Step-by-step proof
tools/callwithname: "collect-user-info-task"andtask: { ttl: 60000 }createTask, returnsCreateTaskResulttasks/getwith thetaskIdProtocoldispatches totaskHandlerHooks.getTask(taskId, extra)McpServer's hook callshandler.getTask({ ...extra, taskId, taskStore })— one argumentasync getTask(_args, { taskId, taskStore: getTaskStore })receives:_args = { signal, sessionId, taskId, taskStore, ... }and the second parameter =undefined{ taskId, taskStore: getTaskStore }fromundefinedthrowsTypeError: Cannot destructure property 'taskId' of 'undefined'Impact
This is a runtime crash in the example server. Any client that creates a task using the
collect-user-info-tasktool and then polls its status will crash the server. Thedelaytool works correctly since it was updated.Fix
Update lines 382-388 to use the new single-argument signature, matching the
delaytool: