Skip to content

[v1.x] Fix registerToolTask's getTask and getTaskResult handlers not being invoked#1335

Open
LucaButBoring wants to merge 5 commits intomodelcontextprotocol:v1.xfrom
LucaButBoring:fix/registerToolTask-v1
Open

[v1.x] Fix registerToolTask's getTask and getTaskResult handlers not being invoked#1335
LucaButBoring wants to merge 5 commits intomodelcontextprotocol:v1.xfrom
LucaButBoring:fix/registerToolTask-v1

Conversation

@LucaButBoring
Copy link
Contributor

Note: This is the v1 backport of #1332.

This PR fixes a bug where custom getTask and getTaskResult handlers registered via registerToolTask were never invoked. The Protocol class's task handlers bypassed them entirely and used TaskStore directly. This was a refactoring oversight that was missed due to (1) the existing tests not explicitly checking if those handlers were called, and (2) setTimeout being used in createTask in many tests inadvertently masking the issue.

This also removes the argument-forwarding to getTask and getTaskResult, as that was originally built before the current TaskStore design was finalized, which broke the assumption that the original request would reliably be stored by the implementor. The current TaskStore design allows the Request to be saved, but does not require that, and also exposes no way to directly retrieve it in getTask or getTaskResult (it was possible but no longer intended at the time of the rewrite).

getTask and getTaskResult now only have the extra argument.

Motivation and Context

When using registerToolTask, developers could provide custom getTask and getTaskResult handlers:

mcpServer.experimental.tasks.registerToolTask('test-tool', options, {
    createTask: async (args, extra) => { /* ... */ },
    getTask: async (args, extra) => { /* not called */ },
    getTaskResult: async (args, extra) => { /* not called */ }
});

These handlers were never invoked because:

  1. The Protocol class's tasks/get and tasks/result handlers directly called TaskStore instead of forwarding to the custom handlers.
  2. McpServer's backwards-compat polling wrapper also bypassed the custom handlers
  3. Tests used setTimeout to complete tasks and did not explicitly assert on the handlers being called, inadvertently masking the issue since tasks completed regardless of whether handlers were invoked

How Has This Been Tested?

Updated unit tests with stricter/more robust assertions.

Breaking Changes

Yes, due to args no longer being passed to getTask or getTaskResult. We could defer this part of the PR to v2.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@LucaButBoring LucaButBoring requested a review from a team as a code owner December 23, 2025 22:47
@changeset-bot
Copy link

changeset-bot bot commented Dec 23, 2025

⚠️ No Changeset found

Latest commit: a4e24fb

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 23, 2025

Open in StackBlitz

npm i https://pkg.pr.new/@modelcontextprotocol/sdk@1335

commit: a4e24fb

@KKonstantinov
Copy link
Contributor

@claude review

Comment on lines +608 to +611
async getTask({ taskId, taskStore }) {
return await taskStore.getTask(taskId);
},
async getTaskResult(_args, { taskId, taskStore }) {
async getTaskResult({ taskId, taskStore }) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: collect-user-info-task at lines 382-388 still uses the old two-argument signature (_args, { taskId, taskStore }) for getTask and getTaskResult, while the PR updated TaskRequestHandler to accept a single extra argument. This will cause a TypeError: Cannot destructure property 'taskId' of 'undefined' at runtime when these handlers are invoked, since the entire extra object is passed as the first argument and undefined as the second. The delay tool at lines 608-614 was correctly updated but this tool was missed.

Extended reasoning...

What the bug is

The PR changes TaskRequestHandler from a multi-argument type (BaseToolCallback<SendResultT, TaskRequestHandlerExtra, Args>) to a single-argument function type ((extra: TaskRequestHandlerExtra) => SendResultT | Promise<SendResultT>). This means getTask and getTaskResult handlers 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, the McpServer constructor sets up taskHandlerHooks that call handler.getTask({ ...extra, taskId, taskStore }) and handler.getTaskResult({ ...extra, taskId, taskStore }) with a single argument. When collect-user-info-task's handler at line 382 declares async getTask(_args, { taskId, taskStore: getTaskStore }), JavaScript passes the entire { ...extra, taskId, taskStore } object as _args and undefined as the second parameter.

Why existing code doesn't prevent it

The delay tool was correctly updated in this PR (lines 608-614 in the diff show the fix from (_args, { taskId, taskStore }) to ({ taskId, taskStore })), but the collect-user-info-task tool 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-parameter TaskRequestHandler type, but example files may not be compiled as part of CI.

Step-by-step proof

  1. Client sends tools/call with name: "collect-user-info-task" and task: { ttl: 60000 }
  2. Server creates the task via createTask, returns CreateTaskResult
  3. Client later calls tasks/get with the taskId
  4. Protocol dispatches to taskHandlerHooks.getTask(taskId, extra)
  5. McpServer's hook calls handler.getTask({ ...extra, taskId, taskStore }) — one argument
  6. The old handler signature async getTask(_args, { taskId, taskStore: getTaskStore }) receives: _args = { signal, sessionId, taskId, taskStore, ... } and the second parameter = undefined
  7. Destructuring { taskId, taskStore: getTaskStore } from undefined throws TypeError: Cannot destructure property 'taskId' of 'undefined'
  8. The server crashes with an unhandled exception

Impact

This is a runtime crash in the example server. Any client that creates a task using the collect-user-info-task tool and then polls its status will crash the server. The delay tool works correctly since it was updated.

Fix

Update lines 382-388 to use the new single-argument signature, matching the delay tool:

async getTask({ taskId, taskStore: getTaskStore }) {
    return await getTaskStore.getTask(taskId);
},
async getTaskResult({ taskId, taskStore: getResultTaskStore }) {
    const result = await getResultTaskStore.getTaskResult(taskId);
    return result as CallToolResult;
}

private _registeredTools: { [name: string]: RegisteredTool } = {};
private _registeredPrompts: { [name: string]: RegisteredPrompt } = {};
private _experimental?: { tasks: ExperimentalMcpServerTasks };
private _taskToolMap: Map<string, string> = new Map();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 _taskToolMap entries (taskId → toolName) are added at line 248 but never removed — there is no .delete() or .clear() call anywhere. For long-running servers processing many tasks, this map grows unboundedly even after tasks reach terminal states or expire via TTL. Consider adding cleanup when a task completes/fails/is cancelled, or lazily when _getTaskHandler finds a task no longer exists in the store.

Extended reasoning...

What the bug is

The _taskToolMap field (declared at line 85 as Map<string, string>) stores a mapping from taskId to the tool name that created it. Entries are added at line 248 via this._taskToolMap.set(taskResult.task.taskId, request.params.name) whenever a task-augmented tool call returns a CreateTaskResult. However, there is no corresponding .delete() call anywhere in the codebase — entries persist for the lifetime of the McpServer instance.

How it manifests

Every task creation adds a string → string entry to the map. When a task completes, fails, is cancelled, or expires via its TTL and gets cleaned up from the TaskStore, the corresponding _taskToolMap entry remains. Over time, for a server that processes many tasks, this map grows monotonically.

Step-by-step proof

  1. A client calls tools/call with task: { ttl: 60000 } for a registered tool task.
  2. The CallToolRequestSchema handler at line 243-249 executes: this._taskToolMap.set(taskResult.task.taskId, request.params.name).
  3. The task completes — TaskStore.storeTaskResult() is called, the task enters a terminal state.
  4. The task's TTL expires and InMemoryTaskStore cleans it up internally.
  5. The _taskToolMap still holds the taskId → toolName entry. There is no code path that removes it.
  6. Repeat steps 1-5 thousands of times — the map now holds thousands of stale entries.

Why existing code doesn't prevent it

Searching for all references to _taskToolMap reveals exactly three: the declaration (line 85), a .get() call (line 111), and the .set() call (line 248). No .delete(), .clear(), or any other cleanup mechanism exists.

Impact

Each 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 fix

The simplest approach would be to add a lazy cleanup in _getTaskHandler: if the taskId is found in _taskToolMap but the task no longer exists in the TaskStore, delete the entry. Alternatively, cleanup could be added when a task reaches a terminal state (in the taskHandlerHooks or after storeTaskResult calls).

LucaButBoring and others added 5 commits March 26, 2026 16:25
Previously, the code only called the underlying task store, and the tests were not complex enough to validate that the handlers were being called, so they missed this.
They weren't being populated correctly, and can't be without changing the TaskStore interface to require restoring the original request when retrieving a Task.
This removes the setTimeout logic we had in tests, which was masking an issue where the getTask handlers weren't being called.
The appropriate logic has been moved into the getTask handlers themselves.
- Update collect-user-info-task example to use single-arg getTask/getTaskResult
  signature matching the updated TaskRequestHandler type
- Clean up _taskToolMap entries after getTaskResult to prevent unbounded growth
@felixweinberger felixweinberger force-pushed the fix/registerToolTask-v1 branch from 30f2a51 to a4e24fb Compare March 26, 2026 16:27
@felixweinberger
Copy link
Contributor

Rebased onto v1.x and pushed a couple of fixes from the review:

  • Updated the collect-user-info-task example to use the single-arg getTask/getTaskResult signature
  • Added cleanup of _taskToolMap entries after getTaskResult completes to prevent unbounded growth

Build and tests pass locally now. @LucaButBoring — when you get a chance, mind taking a look to make sure this still matches your intent? Since #1332 was superseded by #1764 on main with a different approach, want to confirm we're happy keeping this simpler fix on v1.x rather than backporting the larger refactor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants