-
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
d044ace
5374142
272a20a
fcf3d49
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,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/sdk': patch | ||
| --- | ||
|
|
||
| Fix registerToolTask custom getTask/getTaskResult handlers not being invoked |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,9 +82,45 @@ | |
| 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 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. The cleanup added in the The The original suggestion of lazy cleanup in 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. test 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. The finally-based deletion issue from the previous follow-up is now fixed -- the delete is on the success path only, so a retry after a transient error in getTaskResult will correctly find the handler again. However, the cancel-path leak is still present. The code comment says "Cleanup on tasks/cancel would require a protocol-level hook and is intentionally left out here", but the original comment suggested lazy cleanup in _getTaskHandler, which requires no protocol hook: This covers the typical cancel-then-poll-to-confirm pattern (the subsequent tasks/get call would trigger lazy eviction). Truly abandoned entries (cancelled with no further polling) would still linger, but those are the smaller concern. |
||
|
|
||
| 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); | ||
| const result = handler | ||
| ? await handler.getTaskResult({ ...extra, taskId, taskStore }) | ||
| : await taskStore.getTaskResult(taskId); | ||
| // Once the result has been retrieved the task is complete; | ||
| // drop the taskId → toolName mapping to avoid unbounded growth. | ||
| // Cleanup on tasks/cancel would require a protocol-level hook and is | ||
| // intentionally left out here. | ||
| this._taskToolMap.delete(taskId); | ||
| return result; | ||
| } | ||
| }; | ||
| this.server = new Server(serverInfo, { | ||
| ...options, | ||
| taskHandlerHooks: { ...options?.taskHandlerHooks, ...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 +251,10 @@ | |
|
|
||
| // 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 +414,28 @@ | |
| 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; | ||
| } | ||
|
Comment on lines
427
to
435
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. 🟣 This is a pre-existing issue: the polling loop in Extended reasoning...What the bug is and how it manifestsThe polling loop in while (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') {
await new Promise(resolve => setTimeout(resolve, pollInterval));
const getTaskResult = await wrappedHandler.getTask(taskExtraComplete);
...
}When The specific code path that triggers it
Why existing code does not prevent itThe Pre-existing nature and why it matters hereThis bug predates the PR — the old code also had However, since the PR directly modifies How to fix itReplace the bare // Option A: check at loop top
if (extra.signal.aborted) throw extra.signal.reason;
await new Promise(resolve => setTimeout(resolve, pollInterval));
// Option B: race against signal (cleaner, immediate cancellation)
await Promise.race([
new Promise(r => setTimeout(r, pollInterval)),
new Promise((_, reject) =>
extra.signal.addEventListener('abort', () => reject(extra.signal.reason), { once: true })
)
]);Step-by-step proof
|
||
|
|
||
| // Return the final result | ||
| return (await extra.taskStore.getTaskResult(taskId)) as CallToolResult; | ||
| return await wrappedHandler.getTaskResult(taskExtraComplete); | ||
| } | ||
|
|
||
| private _completionHandlerInitialized = false; | ||
|
|
@@ -1545,3 +1586,24 @@ | |
| 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 // undefined only if tool.inputSchema is undefined | ||
| ? Promise.resolve((handler as ToolTaskHandler<ZodRawShapeCompat>).createTask(args, extra)) | ||
|
Check failure on line 1604 in src/server/mcp.ts
|
||
| : Promise.resolve((handler as ToolTaskHandler<undefined>).createTask(extra)), | ||
| getTask: handler.getTask, | ||
| getTaskResult: handler.getTaskResult | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.